dsh-taskboard 0.7.1 → 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 +25 -2
- package/lib/client.js +14 -9
- package/lib/host/execution.js +124 -48
- 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/scheduled-session.js +50 -0
- package/lib/host/scheduled-session.js.map +1 -0
- 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/index.js +12 -1
- package/lib/index.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 +88 -89
- 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 +138 -45
- package/src/host/routes.ts +4 -3
- package/src/host/scheduled-session.ts +68 -0
- package/src/host/scheduler.ts +65 -16
- package/src/host/session-sync.ts +34 -0
- package/src/host/tools.ts +21 -6
- package/src/index.ts +9 -0
- package/src/shared/api.ts +3 -3
- package/src/shared/protocol.ts +108 -6
- package/src/shared/version.ts +1 -1
package/src/host/execution.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* prompt is submitted as an ordinary user message, and the turn settlement
|
|
7
7
|
* (turn/end reason) is folded back into the task's execution record.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Manual runs open fresh sessions; scheduled runs can resume their previous
|
|
10
|
+
* session while keeping separate execution records and settlement watchers.
|
|
10
11
|
*
|
|
11
12
|
* @module dsh-taskboard/host/execution
|
|
12
13
|
*/
|
|
@@ -16,7 +17,10 @@ import {
|
|
|
16
17
|
effectivePrompt,
|
|
17
18
|
newCommentId,
|
|
18
19
|
newExecutionId,
|
|
20
|
+
nextCronTime,
|
|
19
21
|
normalizeBody,
|
|
22
|
+
parseCron,
|
|
23
|
+
spawnNextCycle,
|
|
20
24
|
type ExecutionRecord,
|
|
21
25
|
type ExecutionRepoEvidence,
|
|
22
26
|
type IsolationMode,
|
|
@@ -35,6 +39,8 @@ export const DEFAULT_MAX_CONCURRENT = 3
|
|
|
35
39
|
|
|
36
40
|
/** Narrow agents face (the registry's create, structurally). */
|
|
37
41
|
export interface AgentsFace {
|
|
42
|
+
/** Restore a compatible scheduled session; undefined means it is unavailable. */
|
|
43
|
+
resumeScheduled?(sessionId: string, options: Parameters<AgentsFace['create']>[0]): Promise<Awaited<ReturnType<AgentsFace['create']>> | undefined>
|
|
38
44
|
create(options: {
|
|
39
45
|
sessionId: string
|
|
40
46
|
meta?: { cwd?: string; agentPreset?: string }
|
|
@@ -44,11 +50,15 @@ export interface AgentsFace {
|
|
|
44
50
|
}): Promise<{
|
|
45
51
|
agent: {
|
|
46
52
|
id: string
|
|
53
|
+
/** Present on the real DSH agent; rechecked immediately before dispatch. */
|
|
54
|
+
readonly status?: 'idle' | 'running'
|
|
47
55
|
followup(message: unknown): void
|
|
48
56
|
inject(message: unknown): void
|
|
49
57
|
whenIdle(): Promise<void>
|
|
50
58
|
}
|
|
51
59
|
dispose(): Promise<void>
|
|
60
|
+
/** Live sessions borrowed from another owner must survive cancelled startup. */
|
|
61
|
+
borrowed?: boolean
|
|
52
62
|
}>
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -269,15 +279,17 @@ export class ExecutionService {
|
|
|
269
279
|
* releasing its run entry, so a success settlement can never race it into
|
|
270
280
|
* the ledger and record a failed run as succeeded.
|
|
271
281
|
*/
|
|
272
|
-
private noteFailure(sessionId: string, message: string): Promise<void> {
|
|
282
|
+
private noteFailure(sessionId: string, message: string, executionId?: string): Promise<void> {
|
|
273
283
|
// The failed session may already have committed work — collect the
|
|
274
284
|
// evidence (best effort) BEFORE marking the execution failed (0.3.1).
|
|
275
|
-
const
|
|
285
|
+
const match = [...this.runs.entries()].find(([id, e]) => e.sessionId === sessionId && (executionId === undefined || id === executionId))
|
|
286
|
+
if (match === undefined) return Promise.resolve()
|
|
287
|
+
const [failedId, entry] = match
|
|
276
288
|
return this.collectEvidence(entry?.prepared).then(evidence =>
|
|
277
289
|
this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
278
290
|
for (const task of ledger.tasks) {
|
|
279
291
|
for (const execution of task.executions) {
|
|
280
|
-
if (execution.
|
|
292
|
+
if (execution.id === failedId && execution.outcome === 'running') {
|
|
281
293
|
execution.outcome = 'failed'
|
|
282
294
|
execution.error = message.slice(0, 500)
|
|
283
295
|
execution.endedAt = this.deps.now()
|
|
@@ -355,7 +367,7 @@ export class ExecutionService {
|
|
|
355
367
|
}
|
|
356
368
|
|
|
357
369
|
const executionId = newExecutionId()
|
|
358
|
-
|
|
370
|
+
let sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
|
|
359
371
|
|
|
360
372
|
// 0. Resolve code isolation (plan §3.2): explicit 'none' → zero git calls;
|
|
361
373
|
// 'worktree' (also the omitted default) → prepare below, degrading to
|
|
@@ -372,7 +384,18 @@ export class ExecutionService {
|
|
|
372
384
|
gate = `no task ${taskId}`
|
|
373
385
|
return undefined
|
|
374
386
|
}
|
|
375
|
-
|
|
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
|
+
}
|
|
397
|
+
if (target.status === 'in_progress' || target.executions.some(e => e.outcome === 'running')
|
|
398
|
+
|| [...this.runs.values()].some(e => target.executions.some(x => x.sessionId === e.sessionId))) {
|
|
376
399
|
gate = 'task is already in progress'
|
|
377
400
|
return undefined
|
|
378
401
|
}
|
|
@@ -400,45 +423,45 @@ export class ExecutionService {
|
|
|
400
423
|
})
|
|
401
424
|
if (gate !== undefined) return { ok: false, error: gate }
|
|
402
425
|
|
|
403
|
-
// 1b. Worktree preparation (fail-soft): any failure degrades this run to
|
|
404
|
-
// the original directory with an isolationNote — the ledger and the
|
|
405
|
-
// execution pipeline itself never fail over git. 0.6.3: preparation
|
|
406
|
-
// builds a whole-workspace MIRROR (root repo + nested repos); a plain
|
|
407
|
-
// single-repo workspace keeps the legacy record shape everywhere.
|
|
408
426
|
let isolationNote: string | undefined
|
|
409
427
|
let prepared: PreparedMirror | undefined
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
{ git: this.deps.git, scanner: this.deps.scanner ?? createRepoScanner() },
|
|
417
|
-
{ workspacePath: workspace.path, taskId: task.id, branch, reuse: options?.reuseWorktree === true },
|
|
418
|
-
)
|
|
419
|
-
if ('mirror' in outcome) {
|
|
420
|
-
prepared = outcome.mirror
|
|
421
|
-
await this.pinBranches(task, prepared)
|
|
422
|
-
// Persist the isolation facts of the run (branch is already on the
|
|
423
|
-
// record from the gate mutation). The root repo keeps the legacy
|
|
424
|
-
// flat fields; non-legacy mirrors also record per-repo entries.
|
|
425
|
-
const root = prepared.repos[0]
|
|
426
|
-
await this.patchExecution(executionId, {
|
|
427
|
-
worktreePath: root?.worktreePath,
|
|
428
|
-
baseCommit: root?.baseCommit,
|
|
429
|
-
...(!isLegacySingle(prepared)
|
|
430
|
-
? { repos: prepared.repos.map(r => ({ repo: r.repo, branch: r.branch, worktreePath: r.worktreePath, baseCommit: r.baseCommit })) }
|
|
431
|
-
: {}),
|
|
432
|
-
})
|
|
433
|
-
} else {
|
|
434
|
-
isolationNote = outcome.note
|
|
435
|
-
// Degraded run: clear the optimistic worktree markers.
|
|
428
|
+
|
|
429
|
+
// Check reusable sessions before resetting any scheduled worktree.
|
|
430
|
+
const prepareIsolation = async (): Promise<void> => {
|
|
431
|
+
if (isolation === 'worktree') {
|
|
432
|
+
if (this.deps.git === undefined) {
|
|
433
|
+
isolationNote = 'git 集成不可用,已在原目录执行'
|
|
436
434
|
await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
|
|
435
|
+
} else {
|
|
436
|
+
const outcome = await prepareMirror(
|
|
437
|
+
{ git: this.deps.git, scanner: this.deps.scanner ?? createRepoScanner() },
|
|
438
|
+
{ workspacePath: workspace.path, taskId: task.id, branch, reuse: options?.reuseWorktree === true },
|
|
439
|
+
)
|
|
440
|
+
if ('mirror' in outcome) {
|
|
441
|
+
prepared = outcome.mirror
|
|
442
|
+
await this.pinBranches(task, prepared)
|
|
443
|
+
// Persist the isolation facts of the run (branch is already on the
|
|
444
|
+
// record from the gate mutation). The root repo keeps the legacy
|
|
445
|
+
// flat fields; non-legacy mirrors also record per-repo entries.
|
|
446
|
+
const root = prepared.repos[0]
|
|
447
|
+
await this.patchExecution(executionId, {
|
|
448
|
+
worktreePath: root?.worktreePath,
|
|
449
|
+
baseCommit: root?.baseCommit,
|
|
450
|
+
...(!isLegacySingle(prepared)
|
|
451
|
+
? { repos: prepared.repos.map(r => ({ repo: r.repo, branch: r.branch, worktreePath: r.worktreePath, baseCommit: r.baseCommit })) }
|
|
452
|
+
: {}),
|
|
453
|
+
})
|
|
454
|
+
} else {
|
|
455
|
+
isolationNote = outcome.note
|
|
456
|
+
// Degraded run: clear the optimistic worktree markers.
|
|
457
|
+
await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
|
|
458
|
+
}
|
|
437
459
|
}
|
|
438
460
|
}
|
|
439
461
|
}
|
|
462
|
+
if (trigger !== 'scheduled') await prepareIsolation()
|
|
440
463
|
|
|
441
|
-
// 2. Create the
|
|
464
|
+
// 2. Create or resume the agent+session inside the task's project, carrying
|
|
442
465
|
// the pinned model — or the deployment default when unpinned (the
|
|
443
466
|
// persona template renders {{model}}, so the session always needs one).
|
|
444
467
|
// The session cwd is ALWAYS the project root: DSH's session model
|
|
@@ -463,9 +486,10 @@ export class ExecutionService {
|
|
|
463
486
|
return { ok: false, error: `preset composition failed: ${message}` }
|
|
464
487
|
}
|
|
465
488
|
let handle: Awaited<ReturnType<AgentsFace['create']>>
|
|
489
|
+
let sessionReuseKey: string | undefined
|
|
466
490
|
try {
|
|
467
491
|
const model = task.model ?? this.deps.defaultModel?.()
|
|
468
|
-
|
|
492
|
+
const createOptions: Parameters<AgentsFace['create']>[0] = {
|
|
469
493
|
sessionId,
|
|
470
494
|
meta: {
|
|
471
495
|
cwd: workspace.path,
|
|
@@ -479,7 +503,22 @@ export class ExecutionService {
|
|
|
479
503
|
},
|
|
480
504
|
} : {}),
|
|
481
505
|
...(composition !== undefined ? { setup: composition.setup } : {}),
|
|
482
|
-
}
|
|
506
|
+
}
|
|
507
|
+
// Persist the effective settings, including resolved defaults. Never
|
|
508
|
+
// continue history under a different project, model, preset or boundary.
|
|
509
|
+
sessionReuseKey = JSON.stringify([
|
|
510
|
+
task.workspaceId, workspace.path, composition?.agentPreset ?? null,
|
|
511
|
+
model?.provider ?? null, model?.model ?? null, model?.reasoningEffort ?? null,
|
|
512
|
+
task.permission ?? DEFAULT_PERMISSION, isolation,
|
|
513
|
+
])
|
|
514
|
+
const previous = trigger === 'scheduled'
|
|
515
|
+
? [...task.executions].reverse().find(e => e.trigger === 'scheduled' && e.sessionId !== undefined)
|
|
516
|
+
: undefined
|
|
517
|
+
const resumed = previous?.sessionReuseKey === sessionReuseKey && previous.sessionId !== undefined
|
|
518
|
+
? await this.deps.agents.resumeScheduled?.(previous.sessionId, createOptions)
|
|
519
|
+
: undefined
|
|
520
|
+
handle = resumed ?? await this.deps.agents.create(createOptions)
|
|
521
|
+
sessionId = handle.agent.id
|
|
483
522
|
} catch (error) {
|
|
484
523
|
const message = error instanceof Error ? error.message : String(error)
|
|
485
524
|
await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })
|
|
@@ -489,6 +528,8 @@ export class ExecutionService {
|
|
|
489
528
|
return { ok: false, error: message }
|
|
490
529
|
}
|
|
491
530
|
|
|
531
|
+
if (trigger === 'scheduled') await prepareIsolation()
|
|
532
|
+
|
|
492
533
|
// R3: the startup path above awaited seconds of git + agent work. A
|
|
493
534
|
// cancel() that landed inside that window already settled the execution
|
|
494
535
|
// (cancelled + task back to todo) — with nothing registered in `runs`,
|
|
@@ -499,7 +540,7 @@ export class ExecutionService {
|
|
|
499
540
|
const stillRunning = await this.deps.store.read(ledger =>
|
|
500
541
|
ledger.tasks.some(t => t.executions.some(e => e.id === executionId && e.outcome === 'running')))
|
|
501
542
|
if (!stillRunning) {
|
|
502
|
-
await handle.dispose().catch(() => { /* best effort */ })
|
|
543
|
+
if (!handle.borrowed) await handle.dispose().catch(() => { /* best effort */ })
|
|
503
544
|
// S1: do not leave the startup artifacts behind a cancelled run either.
|
|
504
545
|
await this.cleanupMirror(prepared, workspace.path)
|
|
505
546
|
return { ok: false, error: 'cancelled during startup' }
|
|
@@ -523,7 +564,26 @@ export class ExecutionService {
|
|
|
523
564
|
} catch { /* cosmetic */ }
|
|
524
565
|
|
|
525
566
|
// 4. Record the session id (execution is really started now).
|
|
526
|
-
await this.
|
|
567
|
+
await this.deps.store.mutate('execution-recorded', ledger => {
|
|
568
|
+
const target = ledger.tasks.find(t => t.id === taskId)
|
|
569
|
+
const execution = target?.executions.find(e => e.id === executionId && e.outcome === 'running')
|
|
570
|
+
if (target === undefined || execution === undefined) return undefined
|
|
571
|
+
Object.assign(execution, { sessionId, ...(trigger === 'scheduled' ? { sessionReuseKey } : {}) })
|
|
572
|
+
target.claimedBy = sessionId
|
|
573
|
+
return [target]
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
// Attach/ledger writes above may yield to manual activity or cancellation.
|
|
577
|
+
// Do not inject a scheduled prompt into an already running conversation.
|
|
578
|
+
const current = this.deps.store.get(taskId)?.executions.find(e => e.id === executionId)
|
|
579
|
+
if (current?.outcome !== 'running' || handle.agent.status === 'running') {
|
|
580
|
+
if (!handle.borrowed) await handle.dispose().catch(() => { /* best effort */ })
|
|
581
|
+
if (current?.outcome === 'running') {
|
|
582
|
+
await this.patchExecution(executionId, { outcome: 'failed', error: 'scheduled session is busy', endedAt: this.deps.now() })
|
|
583
|
+
await this.revertProgress(taskId)
|
|
584
|
+
}
|
|
585
|
+
return { ok: false, error: current?.outcome === 'running' ? 'scheduled session is busy' : 'cancelled during startup' }
|
|
586
|
+
}
|
|
527
587
|
|
|
528
588
|
// 5. Submit the opening pair and settle on quiescence (turn/end errors
|
|
529
589
|
// were already folded by the listener). Two messages, ONE turn:
|
|
@@ -551,6 +611,7 @@ export class ExecutionService {
|
|
|
551
611
|
// when the session did NOT follow the handoff protocol — auto-move the
|
|
552
612
|
// card to in_review with a system comment.
|
|
553
613
|
const settle = (): void => {
|
|
614
|
+
if (!this.runs.has(executionId)) return
|
|
554
615
|
this.runs.delete(executionId)
|
|
555
616
|
void this.settleExecution(executionId, sessionId, prepared)
|
|
556
617
|
}
|
|
@@ -562,7 +623,7 @@ export class ExecutionService {
|
|
|
562
623
|
// succeeded (and auto-moved to in_review). Now only the failure
|
|
563
624
|
// settlement writes, and the run entry is released after it commits.
|
|
564
625
|
void handle.agent.whenIdle().then(settle, () => {
|
|
565
|
-
this.noteFailure(sessionId, 'agent did not reach quiescence')
|
|
626
|
+
this.noteFailure(sessionId, 'agent did not reach quiescence', executionId)
|
|
566
627
|
.then(() => { this.runs.delete(executionId) })
|
|
567
628
|
.catch(() => { this.runs.delete(executionId) })
|
|
568
629
|
})
|
|
@@ -594,7 +655,7 @@ export class ExecutionService {
|
|
|
594
655
|
delete t.claimedAt
|
|
595
656
|
}
|
|
596
657
|
if (t.status === 'in_progress') {
|
|
597
|
-
const commented = t.comments.some(c => c.threadId === sessionId)
|
|
658
|
+
const commented = t.comments.some(c => c.threadId === sessionId && c.createdAt >= (execution.startedAt ?? 0))
|
|
598
659
|
t.comments.push({
|
|
599
660
|
id: newCommentId(),
|
|
600
661
|
body: normalizeBody(commented
|
|
@@ -607,6 +668,38 @@ export class ExecutionService {
|
|
|
607
668
|
t.status = 'in_review'
|
|
608
669
|
t.updatedAt = now
|
|
609
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
|
+
}
|
|
610
703
|
}
|
|
611
704
|
return [t]
|
|
612
705
|
}
|
|
@@ -861,4 +954,4 @@ export class ExecutionService {
|
|
|
861
954
|
return undefined
|
|
862
955
|
})
|
|
863
956
|
}
|
|
864
|
-
}
|
|
957
|
+
}
|
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'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Adapt DSH's live-agent and durable-session APIs for recurring executions. */
|
|
2
|
+
import type { AgentsFace } from './execution.ts'
|
|
3
|
+
|
|
4
|
+
type CreateOptions = Parameters<AgentsFace['create']>[0]
|
|
5
|
+
type Handle = Awaited<ReturnType<AgentsFace['create']>>
|
|
6
|
+
type Header = { cwd?: string; agentPreset?: string }
|
|
7
|
+
|
|
8
|
+
export interface ScheduledSessionDeps {
|
|
9
|
+
agents: {
|
|
10
|
+
get(id: string): (Handle['agent'] & {
|
|
11
|
+
session: { header: Header }
|
|
12
|
+
options: CreateOptions['agentOptions']
|
|
13
|
+
cancel(cause: { kind: 'user' }): void
|
|
14
|
+
}) | undefined
|
|
15
|
+
resume(options: { resumeSessionId: string; agentOptions?: CreateOptions['agentOptions']; setup?: CreateOptions['setup'] }): Promise<Handle>
|
|
16
|
+
}
|
|
17
|
+
persistence(): { stat(id: string): Promise<{ header: Header } | undefined> } | undefined
|
|
18
|
+
isArchived(id: string): boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Only confirmed absence, archival or incompatible configuration permits a
|
|
23
|
+
* replacement session... and so does any resume obstacle: busy, locked or
|
|
24
|
+
* corrupt sessions degrade to a brand-new conversation (issue #26 policy),
|
|
25
|
+
* trading history continuity for guaranteed execution progress.
|
|
26
|
+
*/
|
|
27
|
+
export function scheduledSessionResumer(deps: ScheduledSessionDeps): NonNullable<AgentsFace['resumeScheduled']> {
|
|
28
|
+
return async (sessionId, options) => {
|
|
29
|
+
if (deps.isArchived(sessionId)) return undefined
|
|
30
|
+
const compatible = (header: Header): boolean => header.cwd === options.meta?.cwd
|
|
31
|
+
&& header.agentPreset === options.meta?.agentPreset
|
|
32
|
+
const live = deps.agents.get(sessionId)
|
|
33
|
+
if (live !== undefined) {
|
|
34
|
+
// Busy: never queue behind the running conversation — fall back to a
|
|
35
|
+
// fresh session so the scheduled trigger still makes progress.
|
|
36
|
+
if (live.status !== 'idle') return undefined
|
|
37
|
+
if (!compatible(live.session.header)) return undefined
|
|
38
|
+
const model = options.agentOptions
|
|
39
|
+
if (live.options?.provider !== model?.provider || live.options?.model !== model?.model
|
|
40
|
+
|| live.options?.reasoningEffort !== model?.reasoningEffort) return undefined
|
|
41
|
+
return {
|
|
42
|
+
agent: live,
|
|
43
|
+
borrowed: true,
|
|
44
|
+
// A registry lookup grants no ownership. Cancel this activity without
|
|
45
|
+
// disposing the agent owned by another service (for example the UI).
|
|
46
|
+
dispose: async () => { live.cancel({ kind: 'user' }); await live.whenIdle() },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Unreadable metadata (locked/corrupt/disk trouble) degrades to a fresh
|
|
50
|
+
// session rather than failing the whole scheduled run.
|
|
51
|
+
let stored: { header: Header } | undefined
|
|
52
|
+
try {
|
|
53
|
+
const persistence = deps.persistence()
|
|
54
|
+
stored = await persistence?.stat(sessionId)
|
|
55
|
+
} catch { return undefined }
|
|
56
|
+
if (stored === undefined || !compatible(stored.header)) return undefined
|
|
57
|
+
// Recheck after the asynchronous metadata read, including a GUI resume.
|
|
58
|
+
if (deps.isArchived(sessionId)) return undefined
|
|
59
|
+
if (deps.agents.get(sessionId) !== undefined) return undefined
|
|
60
|
+
try {
|
|
61
|
+
return await deps.agents.resume({
|
|
62
|
+
resumeSessionId: sessionId,
|
|
63
|
+
...(options.agentOptions !== undefined ? { agentOptions: options.agentOptions } : {}),
|
|
64
|
+
...(options.setup !== undefined ? { setup: options.setup } : {}),
|
|
65
|
+
})
|
|
66
|
+
} catch { return undefined }
|
|
67
|
+
}
|
|
68
|
+
}
|
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]
|