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.
- package/README.md +23 -0
- package/lib/client.js +14 -9
- package/lib/host/execution.js +30 -1
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +1 -0
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +46 -11
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/session-sync.js +28 -1
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/tools.js +21 -6
- package/lib/host/tools.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +98 -6
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/board/TaskFormModal.tsx +59 -16
- package/src/client/controller.ts +6 -2
- package/src/client/i18n/en.ts +10 -0
- package/src/client/i18n/zh.ts +10 -0
- package/src/client/styles.ts +5 -0
- package/src/host/execution.ts +45 -0
- package/src/host/routes.ts +4 -3
- package/src/host/scheduler.ts +65 -16
- package/src/host/session-sync.ts +34 -0
- package/src/host/tools.ts +21 -6
- package/src/shared/api.ts +3 -3
- package/src/shared/protocol.ts +106 -6
- package/src/shared/version.ts +1 -1
package/src/client/i18n/zh.ts
CHANGED
|
@@ -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
|
package/src/client/styles.ts
CHANGED
|
@@ -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;
|
package/src/host/execution.ts
CHANGED
|
@@ -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
|
}
|
package/src/host/routes.ts
CHANGED
|
@@ -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'
|
package/src/host/scheduler.ts
CHANGED
|
@@ -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.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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) {
|
package/src/host/session-sync.ts
CHANGED
|
@@ -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 +
|
|
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)
|
|
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
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
718
|
-
*
|
|
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)) {
|
package/src/shared/version.ts
CHANGED