dsh-taskboard 0.7.2 → 0.7.4

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.
@@ -282,6 +282,11 @@ export const zh = {
282
282
  'form.field.mode': '执行方式',
283
283
  'form.mode.claim': '🤝 认领制',
284
284
  'form.mode.claimHint': '项目内会话认领',
285
+ 'form.mode.once': '⏰ 定时执行',
286
+ 'form.mode.onceHint': '指定时间只跑一次',
287
+ 'form.mode.periodic': '🔁 定期执行',
288
+ 'form.mode.periodicHint': '按 cron 到点重复开跑',
289
+ 'form.field.runAt': '触发时间',
285
290
  'form.mode.scheduled': '⏰ 定时执行',
286
291
  'form.mode.scheduledHint': '到点自动开跑',
287
292
  'form.field.cron': 'Cron 表达式',
@@ -316,7 +321,9 @@ export const zh = {
316
321
  'form.hint.needTitle': '请填写标题',
317
322
  'form.hint.needProject': '请选择项目',
318
323
  'form.hint.cronBad': 'Cron 表达式无效(分 时 日 月 周)',
324
+ 'form.hint.runAtBad': '请选择未来的触发时间',
319
325
  'form.hint.nextRun': '下次运行 {time}',
326
+ 'form.hint.onceAt': '将于 {time} 触发一次',
320
327
  'form.hint.saveVersion': '保存后版本 v{v} → v{next}',
321
328
  'form.hint.createClaim': '创建后项目内会话可认领执行',
322
329
  'form.action.runBlockedTitle': '任务正在执行中,不能重复发起',
@@ -490,6 +497,9 @@ export const zh = {
490
497
  'sys.sessionError': '[系统] 会话执行异常:{error};任务已退回待办。',
491
498
  'sys.sessionDone': '[系统] 会话执行完毕,已自动进入待验收。',
492
499
  'sys.cronDead': '[系统] 定时表达式 {cron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。',
500
+ 'sys.periodicHandoff': '[系统] 定期任务本轮执行完毕,定时已由新待办卡 {nextTaskId} 承接,请审查本卡后验收。',
501
+ 'sys.spawnedFrom': '[系统] 定期任务上一轮执行完毕(执行 {executionId}),本卡承接定时继续下一轮;上一轮成果见 {sourceId} 的待验收。',
502
+ 'sys.runAtMissed': '[系统] 定时执行错过触发时间(主机当时未运行),本次不再补跑;可手动执行或修改定时。',
493
503
  'sys.mergeSingle': '[系统] 分支 {branch} 已合并到主工作区(--no-ff)。',
494
504
  'sys.mergeMulti': '[系统] 分支已按仓库合并(--no-ff):{summary}',
495
505
  } as const
@@ -609,6 +609,11 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
609
609
  .dsh-atb-urgency-opt[data-on="true"][data-urgency="relaxed"] { border-color: rgba(62,99,221,.65); background: rgba(62,99,221,.1); }
610
610
 
611
611
  .dsh-atb-mode-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
612
+ /* Execution mode has THREE options (认领 / 定时 / 定期): keep them on one row. */
613
+ .dsh-atb-mode-picker[data-exec="true"] { grid-template-columns: repeat(3, 1fr); }
614
+ .dsh-atb-mode-picker[data-exec="true"] .dsh-atb-mode-opt { padding: 7px 8px; }
615
+ .dsh-atb-mode-picker[data-exec="true"] .dsh-atb-mode-name { font-size: 12px; }
616
+ .dsh-atb-mode-picker[data-exec="true"] .dsh-atb-mode-hint { font-size: 10px; }
612
617
  .dsh-atb-mode-opt {
613
618
  display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
614
619
  padding: 8px 10px; border-radius: 9px; cursor: pointer;
@@ -17,7 +17,10 @@ import {
17
17
  effectivePrompt,
18
18
  newCommentId,
19
19
  newExecutionId,
20
+ nextCronTime,
20
21
  normalizeBody,
22
+ parseCron,
23
+ spawnNextCycle,
21
24
  type ExecutionRecord,
22
25
  type ExecutionRepoEvidence,
23
26
  type IsolationMode,
@@ -381,6 +384,16 @@ export class ExecutionService {
381
384
  gate = `no task ${taskId}`
382
385
  return undefined
383
386
  }
387
+ // A status may change after the scheduler selected/advanced the task.
388
+ // Recheck at the atomic execution gate so terminal tasks cannot be
389
+ // revived by that race. Only todo fires scheduled (periodic or one-
390
+ // shot); an in_review card is finished work and never refires — a
391
+ // periodic run's successor todo card carries the schedule onward.
392
+ // Manual reruns keep their existing semantics.
393
+ if (trigger === 'scheduled' && target.status !== 'todo') {
394
+ gate = `scheduled task is not actionable (${target.status})`
395
+ return undefined
396
+ }
384
397
  if (target.status === 'in_progress' || target.executions.some(e => e.outcome === 'running')
385
398
  || [...this.runs.values()].some(e => target.executions.some(x => x.sessionId === e.sessionId))) {
386
399
  gate = 'task is already in progress'
@@ -655,6 +668,38 @@ export class ExecutionService {
655
668
  t.status = 'in_review'
656
669
  t.updatedAt = now
657
670
  t.updatedBy = { kind: 'system' }
671
+ // Periodic scheduled success (定期执行): the finished card stays
672
+ // here for acceptance while a fresh todo card minted from it
673
+ // carries the cron onward. The cron is consumed either way so
674
+ // the in_review card can never refire.
675
+ if (execution.trigger === 'scheduled' && t.execution.cron !== undefined) {
676
+ const match = parseCron(t.execution.cron)
677
+ const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
678
+ if (next !== undefined) {
679
+ const successor = spawnNextCycle(t, executionId, now)
680
+ t.execution = { mode: 'claim' }
681
+ t.comments.push({
682
+ id: newCommentId(),
683
+ body: normalizeBody(`[系统] 定期任务本轮执行完毕,定时已由新待办卡 ${successor.id} 承接,请审查本卡后验收。`),
684
+ systemKey: 'sys.periodicHandoff',
685
+ systemParams: { nextTaskId: successor.id },
686
+ version: 1,
687
+ createdAt: now,
688
+ })
689
+ ledger.tasks.push(successor)
690
+ return [t, successor]
691
+ }
692
+ // Dead cron (no match within the scan window): consume it with
693
+ // a comment instead of letting the handoff die silently.
694
+ t.execution = { mode: 'claim' }
695
+ t.comments.push({
696
+ id: newCommentId(),
697
+ body: normalizeBody('[系统] 定期表达式已无未来触发时间,本轮结束后定时停用;如需继续请重新设置。'),
698
+ systemKey: 'sys.cronDead',
699
+ version: 1,
700
+ createdAt: now,
701
+ })
702
+ }
658
703
  }
659
704
  return [t]
660
705
  }
@@ -139,7 +139,7 @@ function normalizeTemplateSpec(raw: unknown, now: number): TaskTemplate['task']
139
139
  if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
140
140
  if (permission !== undefined && permission.trim().length > 0) spec.permission = asPermission(permission)
141
141
  if (e.execution !== undefined) {
142
- spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, now)
142
+ spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string; runAt?: unknown }, now)
143
143
  }
144
144
  if (e.model !== undefined) spec.model = normalizeModel(e.model)
145
145
  if (e.checklist !== undefined) {
@@ -651,7 +651,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
651
651
  if (status !== 'backlog' && status !== 'todo') {
652
652
  throw new Error('Error: invalid_transition: a new task must start as backlog or todo')
653
653
  }
654
- const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
654
+ const execution = normalizeExecution((body.execution as { mode?: string; cron?: string; runAt?: unknown } | undefined) ?? {}, options.now())
655
655
  const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
656
656
  const isolationRaw = str(body, 'isolation')
657
657
  // 0.5.0: an omitted isolation is MATERIALIZED from the board
@@ -747,7 +747,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
747
747
  }
748
748
  if (typeof body.blocked === 'boolean') next.blocked = body.blocked
749
749
  // The GUI (task owner surface) may edit model/execution; null clears the model.
750
- if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
750
+ if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string; runAt?: unknown }, options.now())
751
751
  if (body.model === null) next.model = undefined
752
752
  else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
753
753
  // Isolation may change only before the first execution (分支与基线
@@ -821,6 +821,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
821
821
  await store.mutate('task-moved', ledger => {
822
822
  const { index, task } = liveTaskAt(ledger, id)
823
823
  if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
824
+ if (task.status !== 'in_review') throw new Error(`Error: invalid_transition: reject requires in_review (current ${task.status})`)
824
825
  if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
825
826
  next = structuredClone(task)
826
827
  next.status = 'todo'
@@ -92,28 +92,77 @@ export class SchedulerService {
92
92
  const now = this.deps.now()
93
93
  const ledger: TaskLedger = this.deps.store.snapshot()
94
94
  for (const task of ledger.tasks) {
95
- if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue
96
- if (task.execution.nextRunAt === undefined) continue
97
- if (task.status === 'in_progress' || task.trashedAt !== undefined) continue
98
- if (task.execution.nextRunAt > now) continue
95
+ if (task.execution.mode !== 'scheduled' || task.trashedAt !== undefined) continue
96
+ // Only todo fires. A finished periodic run settles in review (its cron
97
+ // moves to a freshly minted successor todo card), and terminal/parked
98
+ // states retain their config so an explicit reopen can resume — none
99
+ // of these ever refire from here.
100
+ if (task.status !== 'todo') continue
99
101
  // At the concurrency cap (S4: checked FRESH per task — runs register
100
102
  // only after agent creation, so a once-per-tick snapshot under-counted
101
- // the startup window): leave nextRunAt in the past and retry next tick
102
- // — advancing here would silently burn this window.
103
+ // the startup window): leave the due time in place and retry next tick
104
+ // — advancing/consuming here would silently burn this window.
103
105
  if (this.deps.execution.inFlight() >= (this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)) continue
104
- const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
105
106
 
106
- // Advance the schedule AND record the trigger in ONE mutation (S13:
107
- // one revision bump, one broadcast, and the two writes can no longer
108
- // straddle a status change), then run unless the window was missed.
109
- await this.advanceAndMark(task.id, now, missed ? undefined : task.execution.nextRunAt)
110
- if (missed) continue
111
- await this.deps.execution.run(task.id, 'scheduled').catch(error => {
112
- console.error('[dsh-taskboard] scheduled run failed:', error)
113
- })
107
+ if (task.execution.cron !== undefined) {
108
+ // Periodic (定期执行): refire at every cron match.
109
+ if (task.execution.nextRunAt === undefined) continue
110
+ if (task.execution.nextRunAt > now) continue
111
+ const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
112
+
113
+ // Advance the schedule AND record the trigger in ONE mutation (S13:
114
+ // one revision bump, one broadcast, and the two writes can no longer
115
+ // straddle a status change), then run unless the window was missed.
116
+ await this.advanceAndMark(task.id, now, missed ? undefined : task.execution.nextRunAt)
117
+ if (missed) continue
118
+ await this.deps.execution.run(task.id, 'scheduled').catch(error => {
119
+ console.error('[dsh-taskboard] scheduled run failed:', error)
120
+ })
121
+ } else if (task.execution.runAt !== undefined) {
122
+ // One-shot (定时执行): fire once at the instant, then consume it.
123
+ const due = task.execution.runAt
124
+ if (due > now) continue
125
+ const missed = now - due > SKIP_AFTER_MS
126
+
127
+ // Consume the one-shot AND record the trigger in ONE mutation, so a
128
+ // failure can never re-fire it (the settle path returns the card to
129
+ // todo with a comment, and the gone runAt keeps it there).
130
+ await this.consumeRunAt(task.id, now, missed ? due : undefined)
131
+ if (missed) continue
132
+ await this.deps.execution.run(task.id, 'scheduled').catch(error => {
133
+ console.error('[dsh-taskboard] scheduled run failed:', error)
134
+ })
135
+ }
114
136
  }
115
137
  }
116
138
 
139
+ /**
140
+ * Consume a one-shot task's runAt in one serial-queue mutation: the field
141
+ * and nextRunAt are cleared the moment the trigger fires, so the task can
142
+ * never fire twice. A window missed while the host was down is consumed
143
+ * too, with a system comment instead of a silent drop.
144
+ */
145
+ private async consumeRunAt(taskId: string, now: number, missedDue: number | undefined): Promise<void> {
146
+ await this.deps.store.mutate('task-updated', (ledger) => {
147
+ const task = ledger.tasks.find(t => t.id === taskId)
148
+ if (task === undefined || task.execution.runAt === undefined) return undefined
149
+ if (task.status !== 'todo' || task.trashedAt !== undefined) return undefined
150
+ delete task.execution.runAt
151
+ task.execution.nextRunAt = undefined
152
+ task.execution.lastTriggeredAt = now
153
+ if (missedDue !== undefined) {
154
+ task.comments.push({
155
+ id: newCommentId(),
156
+ body: normalizeBody('[系统] 定时执行错过触发时间(主机当时未运行),本次不再补跑;可手动执行或修改定时。'),
157
+ systemKey: 'sys.runAtMissed',
158
+ version: 1,
159
+ createdAt: now,
160
+ })
161
+ }
162
+ return [task]
163
+ })
164
+ }
165
+
117
166
  /**
118
167
  * Recompute the next run and record the trigger instant for one scheduled
119
168
  * task, in one serial-queue mutation. S12: a cron that can no longer match
@@ -126,7 +175,7 @@ export class SchedulerService {
126
175
  await this.deps.store.mutate('task-updated', (ledger) => {
127
176
  const task = ledger.tasks.find(t => t.id === taskId)
128
177
  if (task === undefined || task.execution.cron === undefined) return undefined
129
- if (task.status === 'in_progress' || task.trashedAt !== undefined) return undefined
178
+ if (task.status !== 'todo' || task.trashedAt !== undefined) return undefined
130
179
  const match = parseCron(task.execution.cron)
131
180
  const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
132
181
  if (next === undefined) {
@@ -15,8 +15,11 @@ import {
15
15
  newCommentId,
16
16
  newExecutionId,
17
17
  newTaskId,
18
+ nextCronTime,
18
19
  normalizeBody,
19
20
  normalizeTitle,
21
+ parseCron,
22
+ spawnNextCycle,
20
23
  type TaskRecord,
21
24
  } from '../shared/protocol.ts'
22
25
  import type { EventsFace } from './execution.ts'
@@ -604,8 +607,10 @@ export class ExternalSessionSyncService {
604
607
  if (task === undefined || task.trashedAt !== undefined) return undefined
605
608
 
606
609
  // Settle running execution
610
+ let scheduledTrigger = false
607
611
  for (const exec of task.executions) {
608
612
  if (exec.sessionId === sessionId && exec.outcome === 'running') {
613
+ scheduledTrigger = exec.trigger === 'scheduled'
609
614
  exec.endedAt = now
610
615
  if (isFailure) {
611
616
  exec.outcome = 'failed'
@@ -645,6 +650,35 @@ export class ExternalSessionSyncService {
645
650
  version: 1,
646
651
  createdAt: now,
647
652
  })
653
+ // Periodic scheduled success (定期执行): same handoff as the
654
+ // execution service — the finished card stays in review while a
655
+ // fresh todo card carries the cron onward.
656
+ if (scheduledTrigger && task.execution.cron !== undefined) {
657
+ const match = parseCron(task.execution.cron)
658
+ const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
659
+ if (next !== undefined) {
660
+ const successor = spawnNextCycle(task, undefined, now)
661
+ task.execution = { mode: 'claim' }
662
+ task.comments.push({
663
+ id: newCommentId(),
664
+ body: normalizeBody(`[系统] 定期任务本轮执行完毕,定时已由新待办卡 ${successor.id} 承接,请审查本卡后验收。`),
665
+ systemKey: 'sys.periodicHandoff',
666
+ systemParams: { nextTaskId: successor.id },
667
+ version: 1,
668
+ createdAt: now,
669
+ })
670
+ ledger.tasks.push(successor)
671
+ return [task, successor]
672
+ }
673
+ task.execution = { mode: 'claim' }
674
+ task.comments.push({
675
+ id: newCommentId(),
676
+ body: normalizeBody('[系统] 定期表达式已无未来触发时间,本轮结束后定时停用;如需继续请重新设置。'),
677
+ systemKey: 'sys.cronDead',
678
+ version: 1,
679
+ createdAt: now,
680
+ })
681
+ }
648
682
  }
649
683
  }
650
684
  return [task]
package/src/host/tools.ts CHANGED
@@ -27,11 +27,13 @@ import { defineTool } from './sdk.ts'
27
27
  import {
28
28
  MAX_CHECKLIST_ITEMS,
29
29
  asIsolation,
30
+ asPermission,
30
31
  asStatus,
31
32
  asUrgency,
32
33
  canTransition,
33
34
  checklistFromTexts,
34
35
  defaultIsolationOf,
36
+ defaultPermissionOf,
35
37
  effectivePrompt,
36
38
  isClaim,
37
39
  isClaimedBy,
@@ -87,7 +89,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
87
89
  const lines: string[] = [
88
90
  `任务 ${t.id} 「${t.title}」`,
89
91
  `状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
90
- `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
92
+ `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` 定期 cron=${t.execution.cron}` : ''}${t.execution.runAt !== undefined ? ` 定时(一次) runAt=${new Date(t.execution.runAt).toISOString()}` : ''}`,
91
93
  `隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}${t.branches !== undefined ? `(多仓库镜像 ${Object.keys(t.branches).length + (t.branch !== undefined ? 1 : 0)} 个仓库)` : ''}`,
92
94
  ]
93
95
  const holder = isClaimedBy(t)
@@ -377,7 +379,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
377
379
  description:
378
380
  'Create a task on the board. Required: title, workspaceId (project), urgency (urgent/normal/relaxed). '
379
381
  + 'Optional: description, prompt (sent to a fresh session on execution), status (default todo), '
380
- + 'execution mode (claim|scheduled + cron), model {provider, model} to pin executions to a model. '
382
+ + 'execution mode (claim | scheduled+cron 定期重复 | scheduled+runAt 定时一次), model {provider, model} to pin executions to a model. '
381
383
  + 'Do not track trivial requests as tasks.',
382
384
  parameters: {
383
385
  title: { type: 'string', required: true, description: 'Short imperative line (1..200 chars).' },
@@ -389,10 +391,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
389
391
  execution: {
390
392
  type: 'object',
391
393
  additionalProperties: false,
392
- description: 'Execution config: { mode: "claim" } (default) or { mode: "scheduled", cron: "m h dom mon dow" }.',
394
+ description: 'Execution config: { mode: "claim" } (default) | { mode: "scheduled", cron } periodic (定期, repeats) | { mode: "scheduled", runAt } one-shot (定时, fires once).',
393
395
  properties: {
394
396
  mode: { type: 'string', description: 'claim | scheduled.' },
395
- cron: { type: 'string', description: 'Five-field cron expression (scheduled only).' },
397
+ cron: { type: 'string', description: 'Five-field cron expression (scheduled periodic only).' },
398
+ runAt: { type: 'string', description: 'One-shot trigger time: epoch ms or ISO string (scheduled one-shot only; must be in the future).' },
396
399
  },
397
400
  },
398
401
  model: {
@@ -409,6 +412,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
409
412
  type: 'string',
410
413
  description: 'Code isolation for executions: "worktree" (each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction). Omitted → the board default (看板设置 → 默认执行隔离; factory default "none").',
411
414
  },
415
+ permission: {
416
+ type: 'string',
417
+ description: 'Execution permission: "read-only", "workspace-write", or "danger-full-access". Omitted → the board default (看板设置 → 默认权限; factory default "workspace-write").',
418
+ },
412
419
  presetId: {
413
420
  type: 'string',
414
421
  description: 'Agent preset the execution session is composed from (its tool set / persona); default = the deployment default preset. Optional.',
@@ -434,9 +441,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
434
441
  status?: string
435
442
  description?: string
436
443
  prompt?: string
437
- execution?: { mode?: string; cron?: string }
444
+ execution?: { mode?: string; cron?: string; runAt?: string | number }
438
445
  model?: { provider?: string; model?: string }
439
446
  isolation?: string
447
+ permission?: string
440
448
  presetId?: string
441
449
  checklist?: string[]
442
450
  }, exec: unknown) {
@@ -457,6 +465,9 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
457
465
  // (看板设置) at creation, so later setting changes never rewrite
458
466
  // existing tasks.
459
467
  const isolation = args.isolation === undefined ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation)
468
+ // Match the GUI create route: freeze the current board default onto
469
+ // the task so later settings changes do not silently alter a schedule.
470
+ const permission = args.permission === undefined ? defaultPermissionOf(store.snapshot().settings) : asPermission(args.permission)
460
471
  const presetId = args.presetId?.trim() || undefined
461
472
  // T9: match the GUI create route — trim and drop blank lines instead
462
473
  // of failing the whole call over one empty string.
@@ -475,6 +486,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
475
486
  execution,
476
487
  model,
477
488
  isolation,
489
+ permission,
478
490
  ...(presetId !== undefined ? { presetId } : {}),
479
491
  ...(checklist !== undefined ? { checklist } : {}),
480
492
  version: 1,
@@ -498,7 +510,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
498
510
  disposers.push(register(defineTool({
499
511
  name: 'taskboard_update',
500
512
  description:
501
- 'Update a task\'s title/description/prompt/urgency/blocked. Requires ifVersion (read first). '
513
+ 'Update a task\'s title/description/prompt/urgency/blocked/permission. Requires ifVersion (read first). '
502
514
  + 'The model and execution config are read-only through this tool (they belong to the task owner/user).',
503
515
  parameters: {
504
516
  id: { type: 'string', required: true, description: 'Task id.' },
@@ -508,6 +520,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
508
520
  prompt: { type: 'string', description: 'New execution prompt.' },
509
521
  urgency: { type: 'string', description: 'urgent | normal | relaxed.' },
510
522
  blocked: { type: 'boolean', description: 'Blocked marker (work cannot continue right now).' },
523
+ permission: { type: 'string', description: 'Execution permission: read-only | workspace-write | danger-full-access.' },
511
524
  },
512
525
  output: {
513
526
  schema: JSON_OUT,
@@ -525,6 +538,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
525
538
  prompt?: string
526
539
  urgency?: string
527
540
  blocked?: boolean
541
+ permission?: string
528
542
  }, exec: unknown) {
529
543
  try {
530
544
  const { actor } = caller(exec as ToolRunContext)
@@ -542,6 +556,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
542
556
  if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
543
557
  if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
544
558
  if (args.blocked !== undefined) next.blocked = args.blocked
559
+ if (args.permission !== undefined) next.permission = asPermission(args.permission)
545
560
  next.version = task.version + 1
546
561
  next.updatedAt = deps.now()
547
562
  next.updatedBy = actor
package/src/shared/api.ts CHANGED
@@ -54,7 +54,7 @@ export type CreateTaskBody = {
54
54
  urgency: string
55
55
  description?: string
56
56
  prompt?: string
57
- execution?: { mode?: string; cron?: string }
57
+ execution?: { mode?: string; cron?: string; runAt?: string | number }
58
58
  model?: TaskModel
59
59
  /** Code isolation for executions ('worktree' | 'none'); omitted = default. */
60
60
  isolation?: string
@@ -76,7 +76,7 @@ export type UpdateTaskBody = {
76
76
  blocked?: boolean
77
77
  /** Rebind the task to another project (GUI owner surface only). */
78
78
  workspaceId?: string
79
- execution?: { mode?: string; cron?: string }
79
+ execution?: { mode?: string; cron?: string; runAt?: string | number }
80
80
  model?: TaskModel | null
81
81
  /** Change isolation; locked once the task has execution history. */
82
82
  isolation?: string
@@ -170,7 +170,7 @@ export type TaskTemplateSpec = {
170
170
  description?: string
171
171
  prompt?: string
172
172
  urgency?: string
173
- execution?: { mode?: string; cron?: string }
173
+ execution?: { mode?: string; cron?: string; runAt?: string | number }
174
174
  model?: TaskModel
175
175
  isolation?: string
176
176
  presetId?: string
@@ -52,7 +52,7 @@ const TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {
52
52
  todo: ['in_progress', 'backlog', 'canceled'],
53
53
  in_progress: ['in_review', 'todo', 'canceled'],
54
54
  in_review: ['in_progress', 'todo', 'done', 'canceled'],
55
- done: ['archived'],
55
+ done: ['archived', 'todo'],
56
56
  canceled: ['archived', 'todo'],
57
57
  archived: [],
58
58
  }
@@ -220,8 +220,18 @@ export type ExecutionMode = 'claim' | 'scheduled'
220
220
  */
221
221
  export interface ExecutionConfig {
222
222
  mode: ExecutionMode
223
- /** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */
223
+ /**
224
+ * Five-field cron expression (minute hour day month weekday). Present on
225
+ * PERIODIC scheduled tasks (定期执行): the scheduler refires the task each
226
+ * time it comes due while the card sits in todo.
227
+ */
224
228
  cron?: string
229
+ /**
230
+ * One-shot trigger time (epoch ms). Present on ONE-SHOT scheduled tasks
231
+ * (定时执行): the scheduler fires the task once when due and consumes the
232
+ * field. Mutually exclusive with {@link cron}.
233
+ */
234
+ runAt?: number
225
235
  /** Next due time (epoch ms); maintained by the host scheduler. */
226
236
  nextRunAt?: number
227
237
  /** Last time the scheduler triggered this task (epoch ms). */
@@ -573,6 +583,13 @@ export type TaskRecord = {
573
583
  executions: ExecutionRecord[]
574
584
  /** How many older execution records were pruned by the retention cap. */
575
585
  executionsPruned?: number
586
+ /**
587
+ * Id of the task this card continues (0.7.x periodic execution): when a
588
+ * PERIODIC scheduled task succeeds, the finished card moves to in_review
589
+ * and a fresh todo card carrying the cron is minted to keep the cycle
590
+ * going. Absent on originally created tasks.
591
+ */
592
+ spawnedFrom?: string
576
593
  /** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */
577
594
  trashedAt?: number
578
595
  }
@@ -593,6 +610,60 @@ export function pruneExecutions(task: TaskRecord): void {
593
610
  task.executionsPruned = (task.executionsPruned ?? 0) + dropped
594
611
  }
595
612
 
613
+ /**
614
+ * Mint the successor card of a PERIODIC scheduled task that just succeeded
615
+ * (0.7.x): the finished card goes to in_review for acceptance while this
616
+ * fresh todo card carries the cron onward, keeping the cycle alive. The
617
+ * next run is recomputed from `now` (no compensating catch-up burst).
618
+ * Pure: the caller pushes the returned record into the ledger.
619
+ * @param source - the finished periodic task (still carrying its cron).
620
+ * @param prevExecutionId - id of the execution that just succeeded.
621
+ * @param now - current epoch ms.
622
+ * @returns the successor task record.
623
+ */
624
+ export function spawnNextCycle(source: TaskRecord, prevExecutionId: string | undefined, now: number): TaskRecord {
625
+ const cron = source.execution.cron
626
+ if (cron === undefined) throw new Error('spawnNextCycle: source task has no cron')
627
+ const match = parseCron(cron)
628
+ const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
629
+ if (next === undefined) throw new Error('spawnNextCycle: cron has no upcoming match within 4 years')
630
+ return {
631
+ id: newTaskId(),
632
+ title: source.title,
633
+ description: source.description,
634
+ prompt: source.prompt,
635
+ workspaceId: source.workspaceId,
636
+ urgency: source.urgency,
637
+ status: 'todo',
638
+ blocked: false,
639
+ execution: { mode: 'scheduled', cron, nextRunAt: next },
640
+ ...(source.model !== undefined ? { model: structuredClone(source.model) } : {}),
641
+ ...(source.isolation !== undefined ? { isolation: source.isolation } : {}),
642
+ ...(source.presetId !== undefined ? { presetId: source.presetId } : {}),
643
+ ...(source.permission !== undefined ? { permission: source.permission } : {}),
644
+ ...(source.checklist !== undefined
645
+ ? { checklist: source.checklist.map(item => ({ ...item, checked: false, checkedBy: undefined, checkedAt: undefined, note: undefined })) }
646
+ : {}),
647
+ ...(source.branch !== undefined ? { branch: source.branch } : {}),
648
+ ...(source.branches !== undefined ? { branches: { ...source.branches } } : {}),
649
+ spawnedFrom: source.id,
650
+ version: 1,
651
+ createdAt: now,
652
+ updatedAt: now,
653
+ createdBy: { kind: 'system' },
654
+ updatedBy: { kind: 'system' },
655
+ comments: [{
656
+ id: newCommentId(),
657
+ body: normalizeBody(`[系统] 定期任务上一轮执行完毕(执行 ${prevExecutionId ?? '未知'}),本卡承接定时继续下一轮;上一轮成果见 ${source.id} 的待验收。`),
658
+ systemKey: 'sys.spawnedFrom',
659
+ systemParams: { sourceId: source.id, executionId: prevExecutionId ?? '' },
660
+ version: 1,
661
+ createdAt: now,
662
+ }],
663
+ executions: [],
664
+ }
665
+ }
666
+
596
667
  /** The whole durable ledger. */
597
668
  export type TaskLedger = {
598
669
  schemaVersion: number
@@ -712,24 +783,52 @@ export function asStatus(raw: string): TaskStatus {
712
783
  return raw as TaskStatus
713
784
  }
714
785
 
786
+ /**
787
+ * Parse a raw runAt input: epoch ms number or ISO date string → epoch ms.
788
+ * @param raw - untyped runAt value.
789
+ * @returns the epoch ms, or undefined when absent.
790
+ */
791
+ function normalizeRunAt(raw: unknown): number | undefined {
792
+ if (raw === undefined || raw === null) return undefined
793
+ if (typeof raw === 'number' && Number.isFinite(raw)) return Math.trunc(raw)
794
+ if (typeof raw === 'string') {
795
+ const t = Date.parse(raw)
796
+ if (Number.isNaN(t)) throw new Error('execution.runAt is not a valid time (epoch ms or ISO string)')
797
+ return t
798
+ }
799
+ throw new Error('execution.runAt must be an epoch ms number or an ISO date string')
800
+ }
801
+
715
802
  /**
716
803
  * Validate an execution config request from raw tool/route input.
717
- * `scheduled` requires a valid cron; computes the first `nextRunAt` from
718
- * `now`.
804
+ * `scheduled` requires either a valid cron (periodic, 定期执行) or a runAt
805
+ * instant (one-shot, 定时执行); the two are mutually exclusive. A cron's
806
+ * first `nextRunAt` is computed from `now`.
719
807
  * @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).
720
808
  * @param now - current epoch ms.
809
+ * @param opts - `allowPastRunAt` lets the import path keep a historical
810
+ * one-shot instant instead of rejecting it.
721
811
  * @returns the normalized config.
722
812
  */
723
813
  export function normalizeExecution(
724
- raw: { mode?: string; cron?: string },
814
+ raw: { mode?: string; cron?: string; runAt?: unknown },
725
815
  now: number,
816
+ opts?: { allowPastRunAt?: boolean },
726
817
  ): ExecutionConfig {
727
818
  const mode = raw.mode ?? 'claim'
728
819
  if (mode !== 'claim' && mode !== 'scheduled') {
729
820
  throw new Error("execution.mode must be 'claim' or 'scheduled'")
730
821
  }
731
822
  if (mode === 'claim') return { mode }
823
+ const runAt = normalizeRunAt(raw.runAt)
732
824
  const cron = (raw.cron ?? '').trim()
825
+ if (cron.length > 0 && runAt !== undefined) {
826
+ throw new Error('execution: cron and runAt are mutually exclusive (periodic vs one-shot)')
827
+ }
828
+ if (runAt !== undefined) {
829
+ if (!opts?.allowPastRunAt && runAt <= now) throw new Error('execution.runAt must be in the future')
830
+ return { mode, runAt, nextRunAt: runAt }
831
+ }
733
832
  const match = parseCron(cron)
734
833
  if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')
735
834
  const next = nextCronTime(match, now)
@@ -1055,8 +1154,9 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
1055
1154
  if (!isValidTaskId(id)) return fail('missing/invalid id (must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$)')
1056
1155
  try {
1057
1156
  const execution = normalizeExecution(
1058
- typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},
1157
+ typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string; runAt?: unknown } : {},
1059
1158
  now,
1159
+ { allowPastRunAt: true },
1060
1160
  )
1061
1161
  const comments: CommentRecord[] = []
1062
1162
  if (Array.isArray(e.comments)) {
@@ -6,5 +6,5 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.7.1'
9
+ export const PLUGIN_VERSION = '0.7.4'
10
10