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.
@@ -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
- * Every execution is a NEW session: clean context, no reuse of previous runs.
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 entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
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.sessionId === sessionId && execution.outcome === 'running') {
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
- const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
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
- if (target.status === 'in_progress') {
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
- if (isolation === 'worktree') {
411
- if (this.deps.git === undefined) {
412
- isolationNote = 'git 集成不可用,已在原目录执行'
413
- await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
414
- } else {
415
- const outcome = await prepareMirror(
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 fresh agent+session inside the task's project, carrying
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
- handle = await this.deps.agents.create({
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.patchExecution(executionId, { sessionId })
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
+ }
@@ -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
+ }
@@ -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]