dsh-taskboard 0.7.0 → 0.7.2

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/package.json CHANGED
@@ -1,91 +1,91 @@
1
- {
2
- "name": "dsh-taskboard",
3
- "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, optional per-task git-worktree isolation (dedicated task branches, commit evidence, one-click merge), host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.7.0",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./invariant": "./lib/invariant.js",
10
- "./client": "./lib/client.js",
11
- "./package.json": "./package.json"
12
- },
13
- "dsh": {
14
- "bundle": {
15
- "patch": "./cordis.patch.yml"
16
- },
17
- "client": {
18
- "inject": [],
19
- "platform": "web"
20
- },
21
- "compatibility": {
22
- "dshReleases": {
23
- "0.1.0-rc.8": "unknown",
24
- "0.1.1-rc.1": "unknown",
25
- "0.1.1-rc.2": "compatible",
26
- "0.1.2-alpha.2": "compatible",
27
- "0.1.2-alpha.3": "compatible",
28
- "0.1.2-alpha.4": "compatible",
1
+ {
2
+ "name": "dsh-taskboard",
3
+ "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, optional per-task git-worktree isolation (dedicated task branches, commit evidence, one-click merge), host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
+ "version": "0.7.2",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./invariant": "./lib/invariant.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "dsh": {
14
+ "bundle": {
15
+ "patch": "./cordis.patch.yml"
16
+ },
17
+ "client": {
18
+ "inject": [],
19
+ "platform": "web"
20
+ },
21
+ "compatibility": {
22
+ "dshReleases": {
23
+ "0.1.0-rc.8": "unknown",
24
+ "0.1.1-rc.1": "unknown",
25
+ "0.1.1-rc.2": "compatible",
26
+ "0.1.2-alpha.2": "compatible",
27
+ "0.1.2-alpha.3": "compatible",
28
+ "0.1.2-alpha.4": "compatible",
29
29
  "0.1.2-alpha.5": "compatible",
30
30
  "0.1.2-rc.1": "compatible",
31
31
  "0.1.5-rc.1": "compatible"
32
- }
33
- }
34
- },
35
- "files": [
36
- "lib/**/*.js",
37
- "lib/**/*.js.map",
38
- "src",
39
- "cordis.patch.yml",
40
- "LICENSE"
41
- ],
42
- "license": "Apache-2.0",
43
- "engines": {
44
- "node": ">=22"
45
- },
46
- "author": "cloader",
47
- "repository": {
48
- "type": "git",
49
- "url": "git+https://github.com/cloader/dsh-taskboard.git"
50
- },
51
- "bugs": {
52
- "url": "https://github.com/cloader/dsh-taskboard/issues"
53
- },
54
- "homepage": "https://github.com/cloader/dsh-taskboard#readme",
55
- "keywords": [
56
- "dsh",
57
- "deepseek-harness",
58
- "dsh-plugin",
59
- "taskboard",
60
- "kanban",
61
- "agent"
62
- ],
63
- "scripts": {
64
- "build": "node scripts/clean-lib.mjs && npm run build:host && npm run build:client",
65
- "build:host": "tsdown -c tsdown.host.config.ts",
66
- "build:client": "tsdown -c tsdown.client.config.ts && node scripts/wrap-client.mjs",
67
- "watch": "tsdown -c tsdown.host.config.ts --watch",
68
- "typecheck": "tsc --noEmit",
69
- "test": "vitest run"
70
- },
71
- "devDependencies": {
72
- "@deepseek-ai/cordis": "^4.0.2",
73
- "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
74
- "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.2",
75
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
76
- "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
77
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
78
- "@deepseek-ai/dsh-workspace": "^0.1.5-rc.2",
79
- "@deepseek-ai/schemastery": "^3.18.2",
80
- "@types/node": "^22.20.1",
81
- "@types/react": "~18.3.1",
82
- "@types/react-dom": "^18.3.7",
83
- "jsdom": "^25.0.1",
84
- "puppeteer-core": "^25.8.0",
85
- "react": "^18.3.1",
86
- "react-dom": "^18.3.1",
87
- "tsdown": "0.22.2",
88
- "typescript": "~5.7.2",
89
- "vitest": "^3.0.0"
90
- }
91
- }
32
+ }
33
+ }
34
+ },
35
+ "files": [
36
+ "lib/**/*.js",
37
+ "lib/**/*.js.map",
38
+ "src",
39
+ "cordis.patch.yml",
40
+ "LICENSE"
41
+ ],
42
+ "license": "Apache-2.0",
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "author": "cloader",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/cloader/dsh-taskboard.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/cloader/dsh-taskboard/issues"
53
+ },
54
+ "homepage": "https://github.com/cloader/dsh-taskboard#readme",
55
+ "keywords": [
56
+ "dsh",
57
+ "deepseek-harness",
58
+ "dsh-plugin",
59
+ "taskboard",
60
+ "kanban",
61
+ "agent"
62
+ ],
63
+ "scripts": {
64
+ "build": "node scripts/clean-lib.mjs && npm run build:host && npm run build:client",
65
+ "build:host": "tsdown -c tsdown.host.config.ts",
66
+ "build:client": "tsdown -c tsdown.client.config.ts && node scripts/wrap-client.mjs",
67
+ "watch": "tsdown -c tsdown.host.config.ts --watch",
68
+ "typecheck": "tsc --noEmit",
69
+ "test": "vitest run"
70
+ },
71
+ "devDependencies": {
72
+ "@deepseek-ai/cordis": "^4.0.2",
73
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
74
+ "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.2",
75
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
76
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
77
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
78
+ "@deepseek-ai/dsh-workspace": "^0.1.5-rc.2",
79
+ "@deepseek-ai/schemastery": "^3.18.2",
80
+ "@types/node": "^22.20.1",
81
+ "@types/react": "~18.3.1",
82
+ "@types/react-dom": "^18.3.7",
83
+ "jsdom": "^25.0.1",
84
+ "puppeteer-core": "^25.8.0",
85
+ "react": "^18.3.1",
86
+ "react-dom": "^18.3.1",
87
+ "tsdown": "0.22.2",
88
+ "typescript": "~5.7.2",
89
+ "vitest": "^3.0.0"
90
+ }
91
+ }
@@ -162,8 +162,16 @@ export function SettingsModal({ controller }: { controller: BoardController }) {
162
162
  value={storagePath}
163
163
  disabled={state.storage === undefined || storageBusy}
164
164
  placeholder={state.storage?.defaultDirectory ?? t('set.storage.loading')}
165
- onChange={e => { setStoragePath(e.target.value); setStorageTouched(true) }}
165
+ onChange={e => { setStoragePath(e.target.value); setStorageTouched(true); controller.dismissStorageNotice() }}
166
166
  />
167
+ {state.storageNotice !== undefined && (
168
+ <div className="dsh-atb-storage-notice" role="status">
169
+ <span className="dsh-atb-storage-notice-ok">✓ {t('set.storage.migrated', { path: state.storageNotice.path })}</span>
170
+ {state.storageNotice.warnings.length > 0 && (
171
+ <span className="dsh-atb-storage-notice-warn">{t('set.storage.warnings', { warnings: state.storageNotice.warnings.join('; ') })}</span>
172
+ )}
173
+ </div>
174
+ )}
167
175
  {state.storage !== undefined && (
168
176
  <div className="dsh-atb-storage-meta">
169
177
  <span>{t('set.storage.current', { path: state.storage.currentDirectory })}</span>
@@ -80,6 +80,8 @@ export interface ControllerState {
80
80
  settingsOpen: boolean
81
81
  /** Current durable-data directory, loaded when settings opens. */
82
82
  storage?: StorageStatus
83
+ /** Settled migration outcome (0.7.0): success path plus non-fatal warnings. */
84
+ storageNotice?: { path: string; warnings: string[] }
83
85
  /** Fields a chosen template prefills into the create form (consumed on open). */
84
86
  templatePrefill?: TaskTemplateSpec
85
87
  /** Transient error surface (action failures); cleared on next success. */
@@ -607,7 +609,7 @@ export class BoardController {
607
609
  }
608
610
 
609
611
  /** Close the board-settings modal. */
610
- closeSettings(): void { this.setState({ settingsOpen: false }) }
612
+ closeSettings(): void { this.setState({ settingsOpen: false, storageNotice: undefined }) }
611
613
 
612
614
  /**
613
615
  * Replace board settings (0.5.0). The host broadcasts a settings-updated
@@ -641,7 +643,9 @@ export class BoardController {
641
643
  async migrateStorage(directory: string): Promise<boolean> {
642
644
  try {
643
645
  const storage = await this.client.migrateStorage(directory)
644
- this.setState({ storage, error: storage.warnings.length === 0 ? undefined : storage.warnings.join('\n') })
646
+ // Success is surfaced as a dedicated notice (not the error banner);
647
+ // cleanup warnings render beneath it as warnings, never as errors.
648
+ this.setState({ storage, storageNotice: { path: storage.currentDirectory, warnings: storage.warnings }, error: undefined })
645
649
  await this.refresh()
646
650
  return true
647
651
  } catch (error) {
@@ -650,6 +654,11 @@ export class BoardController {
650
654
  }
651
655
  }
652
656
 
657
+ /** Clear the settled migration notice (next user edit in the storage section). */
658
+ dismissStorageNotice(): void {
659
+ if (this.state.storageNotice !== undefined) this.setState({ storageNotice: undefined })
660
+ }
661
+
653
662
  /** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
654
663
  async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
655
664
  try {
@@ -397,6 +397,8 @@ export const en: TaskboardDict = {
397
397
  'set.storage.migrate': 'Migrate data',
398
398
  'set.storage.migrating': 'Migrating…',
399
399
  'set.storage.confirm': 'Migrate the complete task ledger, templates, and image attachments?\n\nFrom: {from}\nTo: {to}\n\nWrites queue briefly during migration.',
400
+ 'set.storage.migrated': 'Migration succeeded — data now lives in {path}',
401
+ 'set.storage.warnings': 'Migration finished, but some old files could not be removed: {warnings}',
400
402
 
401
403
  // ── execution permission (PR #14) ─────────────────────────────────
402
404
  'form.field.permission': 'Execution permission',
@@ -399,6 +399,8 @@ export const zh = {
399
399
  'set.storage.migrate': '迁移数据',
400
400
  'set.storage.migrating': '迁移中…',
401
401
  'set.storage.confirm': '确认迁移全部任务台账、模板和图片附件?\n\n原路径:{from}\n新路径:{to}\n\n迁移期间写操作会短暂排队。',
402
+ 'set.storage.migrated': '迁移成功,数据已迁移到 {path}',
403
+ 'set.storage.warnings': '迁移完成,但部分旧数据未能清理:{warnings}',
402
404
 
403
405
  // ── execution permission (PR #14) ─────────────────────────────────
404
406
  'form.field.permission': '执行权限',
@@ -890,6 +890,9 @@ color: var(--dsw-alias-state-business-primary, #3e63dd);
890
890
  .dsh-atb-storage-meta { display: grid; gap: 4px; margin-top: 8px; color: var(--dsh-atb-muted); font-size: 12px; overflow-wrap: anywhere; }
891
891
  .dsh-atb-storage-error { color: var(--dsh-atb-danger); }
892
892
  .dsh-atb-storage-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
893
+ .dsh-atb-storage-notice { display: grid; gap: 4px; margin-top: 8px; font-size: 12px; }
894
+ .dsh-atb-storage-notice-ok { color: var(--dsw-alias-state-success-primary, #2e7d32); padding: 4px 8px; border-radius: 6px; background: rgba(46,125,50,.1); }
895
+ .dsh-atb-storage-notice-warn { color: var(--dsw-alias-state-warning-primary, #b8860b); padding: 4px 8px; border-radius: 6px; background: rgba(184,134,11,.1); word-break: break-word; }
893
896
 
894
897
  /* ---------- 0.5.5 SlashPromptInput & Permission Picker ---------- */
895
898
  .dsh-atb-perm-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
@@ -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
  */
@@ -35,6 +36,8 @@ export const DEFAULT_MAX_CONCURRENT = 3
35
36
 
36
37
  /** Narrow agents face (the registry's create, structurally). */
37
38
  export interface AgentsFace {
39
+ /** Restore a compatible scheduled session; undefined means it is unavailable. */
40
+ resumeScheduled?(sessionId: string, options: Parameters<AgentsFace['create']>[0]): Promise<Awaited<ReturnType<AgentsFace['create']>> | undefined>
38
41
  create(options: {
39
42
  sessionId: string
40
43
  meta?: { cwd?: string; agentPreset?: string }
@@ -44,11 +47,15 @@ export interface AgentsFace {
44
47
  }): Promise<{
45
48
  agent: {
46
49
  id: string
50
+ /** Present on the real DSH agent; rechecked immediately before dispatch. */
51
+ readonly status?: 'idle' | 'running'
47
52
  followup(message: unknown): void
48
53
  inject(message: unknown): void
49
54
  whenIdle(): Promise<void>
50
55
  }
51
56
  dispose(): Promise<void>
57
+ /** Live sessions borrowed from another owner must survive cancelled startup. */
58
+ borrowed?: boolean
52
59
  }>
53
60
  }
54
61
 
@@ -269,15 +276,17 @@ export class ExecutionService {
269
276
  * releasing its run entry, so a success settlement can never race it into
270
277
  * the ledger and record a failed run as succeeded.
271
278
  */
272
- private noteFailure(sessionId: string, message: string): Promise<void> {
279
+ private noteFailure(sessionId: string, message: string, executionId?: string): Promise<void> {
273
280
  // The failed session may already have committed work — collect the
274
281
  // evidence (best effort) BEFORE marking the execution failed (0.3.1).
275
- const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
282
+ const match = [...this.runs.entries()].find(([id, e]) => e.sessionId === sessionId && (executionId === undefined || id === executionId))
283
+ if (match === undefined) return Promise.resolve()
284
+ const [failedId, entry] = match
276
285
  return this.collectEvidence(entry?.prepared).then(evidence =>
277
286
  this.deps.store.mutate('execution-recorded', (ledger) => {
278
287
  for (const task of ledger.tasks) {
279
288
  for (const execution of task.executions) {
280
- if (execution.sessionId === sessionId && execution.outcome === 'running') {
289
+ if (execution.id === failedId && execution.outcome === 'running') {
281
290
  execution.outcome = 'failed'
282
291
  execution.error = message.slice(0, 500)
283
292
  execution.endedAt = this.deps.now()
@@ -355,7 +364,7 @@ export class ExecutionService {
355
364
  }
356
365
 
357
366
  const executionId = newExecutionId()
358
- const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
367
+ let sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
359
368
 
360
369
  // 0. Resolve code isolation (plan §3.2): explicit 'none' → zero git calls;
361
370
  // 'worktree' (also the omitted default) → prepare below, degrading to
@@ -372,7 +381,8 @@ export class ExecutionService {
372
381
  gate = `no task ${taskId}`
373
382
  return undefined
374
383
  }
375
- if (target.status === 'in_progress') {
384
+ if (target.status === 'in_progress' || target.executions.some(e => e.outcome === 'running')
385
+ || [...this.runs.values()].some(e => target.executions.some(x => x.sessionId === e.sessionId))) {
376
386
  gate = 'task is already in progress'
377
387
  return undefined
378
388
  }
@@ -400,45 +410,45 @@ export class ExecutionService {
400
410
  })
401
411
  if (gate !== undefined) return { ok: false, error: gate }
402
412
 
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
413
  let isolationNote: string | undefined
409
414
  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.
415
+
416
+ // Check reusable sessions before resetting any scheduled worktree.
417
+ const prepareIsolation = async (): Promise<void> => {
418
+ if (isolation === 'worktree') {
419
+ if (this.deps.git === undefined) {
420
+ isolationNote = 'git 集成不可用,已在原目录执行'
436
421
  await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
422
+ } else {
423
+ const outcome = await prepareMirror(
424
+ { git: this.deps.git, scanner: this.deps.scanner ?? createRepoScanner() },
425
+ { workspacePath: workspace.path, taskId: task.id, branch, reuse: options?.reuseWorktree === true },
426
+ )
427
+ if ('mirror' in outcome) {
428
+ prepared = outcome.mirror
429
+ await this.pinBranches(task, prepared)
430
+ // Persist the isolation facts of the run (branch is already on the
431
+ // record from the gate mutation). The root repo keeps the legacy
432
+ // flat fields; non-legacy mirrors also record per-repo entries.
433
+ const root = prepared.repos[0]
434
+ await this.patchExecution(executionId, {
435
+ worktreePath: root?.worktreePath,
436
+ baseCommit: root?.baseCommit,
437
+ ...(!isLegacySingle(prepared)
438
+ ? { repos: prepared.repos.map(r => ({ repo: r.repo, branch: r.branch, worktreePath: r.worktreePath, baseCommit: r.baseCommit })) }
439
+ : {}),
440
+ })
441
+ } else {
442
+ isolationNote = outcome.note
443
+ // Degraded run: clear the optimistic worktree markers.
444
+ await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
445
+ }
437
446
  }
438
447
  }
439
448
  }
449
+ if (trigger !== 'scheduled') await prepareIsolation()
440
450
 
441
- // 2. Create the fresh agent+session inside the task's project, carrying
451
+ // 2. Create or resume the agent+session inside the task's project, carrying
442
452
  // the pinned model — or the deployment default when unpinned (the
443
453
  // persona template renders {{model}}, so the session always needs one).
444
454
  // The session cwd is ALWAYS the project root: DSH's session model
@@ -463,9 +473,10 @@ export class ExecutionService {
463
473
  return { ok: false, error: `preset composition failed: ${message}` }
464
474
  }
465
475
  let handle: Awaited<ReturnType<AgentsFace['create']>>
476
+ let sessionReuseKey: string | undefined
466
477
  try {
467
478
  const model = task.model ?? this.deps.defaultModel?.()
468
- handle = await this.deps.agents.create({
479
+ const createOptions: Parameters<AgentsFace['create']>[0] = {
469
480
  sessionId,
470
481
  meta: {
471
482
  cwd: workspace.path,
@@ -479,7 +490,22 @@ export class ExecutionService {
479
490
  },
480
491
  } : {}),
481
492
  ...(composition !== undefined ? { setup: composition.setup } : {}),
482
- })
493
+ }
494
+ // Persist the effective settings, including resolved defaults. Never
495
+ // continue history under a different project, model, preset or boundary.
496
+ sessionReuseKey = JSON.stringify([
497
+ task.workspaceId, workspace.path, composition?.agentPreset ?? null,
498
+ model?.provider ?? null, model?.model ?? null, model?.reasoningEffort ?? null,
499
+ task.permission ?? DEFAULT_PERMISSION, isolation,
500
+ ])
501
+ const previous = trigger === 'scheduled'
502
+ ? [...task.executions].reverse().find(e => e.trigger === 'scheduled' && e.sessionId !== undefined)
503
+ : undefined
504
+ const resumed = previous?.sessionReuseKey === sessionReuseKey && previous.sessionId !== undefined
505
+ ? await this.deps.agents.resumeScheduled?.(previous.sessionId, createOptions)
506
+ : undefined
507
+ handle = resumed ?? await this.deps.agents.create(createOptions)
508
+ sessionId = handle.agent.id
483
509
  } catch (error) {
484
510
  const message = error instanceof Error ? error.message : String(error)
485
511
  await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })
@@ -489,6 +515,8 @@ export class ExecutionService {
489
515
  return { ok: false, error: message }
490
516
  }
491
517
 
518
+ if (trigger === 'scheduled') await prepareIsolation()
519
+
492
520
  // R3: the startup path above awaited seconds of git + agent work. A
493
521
  // cancel() that landed inside that window already settled the execution
494
522
  // (cancelled + task back to todo) — with nothing registered in `runs`,
@@ -499,7 +527,7 @@ export class ExecutionService {
499
527
  const stillRunning = await this.deps.store.read(ledger =>
500
528
  ledger.tasks.some(t => t.executions.some(e => e.id === executionId && e.outcome === 'running')))
501
529
  if (!stillRunning) {
502
- await handle.dispose().catch(() => { /* best effort */ })
530
+ if (!handle.borrowed) await handle.dispose().catch(() => { /* best effort */ })
503
531
  // S1: do not leave the startup artifacts behind a cancelled run either.
504
532
  await this.cleanupMirror(prepared, workspace.path)
505
533
  return { ok: false, error: 'cancelled during startup' }
@@ -523,7 +551,26 @@ export class ExecutionService {
523
551
  } catch { /* cosmetic */ }
524
552
 
525
553
  // 4. Record the session id (execution is really started now).
526
- await this.patchExecution(executionId, { sessionId })
554
+ await this.deps.store.mutate('execution-recorded', ledger => {
555
+ const target = ledger.tasks.find(t => t.id === taskId)
556
+ const execution = target?.executions.find(e => e.id === executionId && e.outcome === 'running')
557
+ if (target === undefined || execution === undefined) return undefined
558
+ Object.assign(execution, { sessionId, ...(trigger === 'scheduled' ? { sessionReuseKey } : {}) })
559
+ target.claimedBy = sessionId
560
+ return [target]
561
+ })
562
+
563
+ // Attach/ledger writes above may yield to manual activity or cancellation.
564
+ // Do not inject a scheduled prompt into an already running conversation.
565
+ const current = this.deps.store.get(taskId)?.executions.find(e => e.id === executionId)
566
+ if (current?.outcome !== 'running' || handle.agent.status === 'running') {
567
+ if (!handle.borrowed) await handle.dispose().catch(() => { /* best effort */ })
568
+ if (current?.outcome === 'running') {
569
+ await this.patchExecution(executionId, { outcome: 'failed', error: 'scheduled session is busy', endedAt: this.deps.now() })
570
+ await this.revertProgress(taskId)
571
+ }
572
+ return { ok: false, error: current?.outcome === 'running' ? 'scheduled session is busy' : 'cancelled during startup' }
573
+ }
527
574
 
528
575
  // 5. Submit the opening pair and settle on quiescence (turn/end errors
529
576
  // were already folded by the listener). Two messages, ONE turn:
@@ -551,6 +598,7 @@ export class ExecutionService {
551
598
  // when the session did NOT follow the handoff protocol — auto-move the
552
599
  // card to in_review with a system comment.
553
600
  const settle = (): void => {
601
+ if (!this.runs.has(executionId)) return
554
602
  this.runs.delete(executionId)
555
603
  void this.settleExecution(executionId, sessionId, prepared)
556
604
  }
@@ -562,7 +610,7 @@ export class ExecutionService {
562
610
  // succeeded (and auto-moved to in_review). Now only the failure
563
611
  // settlement writes, and the run entry is released after it commits.
564
612
  void handle.agent.whenIdle().then(settle, () => {
565
- this.noteFailure(sessionId, 'agent did not reach quiescence')
613
+ this.noteFailure(sessionId, 'agent did not reach quiescence', executionId)
566
614
  .then(() => { this.runs.delete(executionId) })
567
615
  .catch(() => { this.runs.delete(executionId) })
568
616
  })
@@ -594,7 +642,7 @@ export class ExecutionService {
594
642
  delete t.claimedAt
595
643
  }
596
644
  if (t.status === 'in_progress') {
597
- const commented = t.comments.some(c => c.threadId === sessionId)
645
+ const commented = t.comments.some(c => c.threadId === sessionId && c.createdAt >= (execution.startedAt ?? 0))
598
646
  t.comments.push({
599
647
  id: newCommentId(),
600
648
  body: normalizeBody(commented
@@ -861,4 +909,4 @@ export class ExecutionService {
861
909
  return undefined
862
910
  })
863
911
  }
864
- }
912
+ }
@@ -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/index.ts CHANGED
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
21
21
  import type {} from '@deepseek-ai/dsh-agent'
22
22
  import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
23
23
  import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
24
+ import { scheduledSessionResumer, type ScheduledSessionDeps } from './host/scheduled-session.ts'
24
25
  import { createGitFace } from './host/git.ts'
25
26
  import { createRepoScanner } from './host/repos.ts'
26
27
  import { registerTaskboardRoutes } from './host/routes.ts'
@@ -183,6 +184,14 @@ export function apply(ctx: Context): void {
183
184
  store,
184
185
  agents: {
185
186
  create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,
187
+ resumeScheduled: scheduledSessionResumer({
188
+ agents: {
189
+ get: id => agentCtx.agents.get(id as never) as unknown as ReturnType<ScheduledSessionDeps['agents']['get']>,
190
+ resume: options => agentCtx.agents.resume(options as never) as Promise<never>,
191
+ },
192
+ persistence: () => agentCtx.get('sessionPersistence') as ReturnType<ScheduledSessionDeps['persistence']>,
193
+ isArchived: id => wsCtx.workspaceRegistry.archivedSessionIds.includes(id as never),
194
+ }),
186
195
  },
187
196
  workspaces: {
188
197
  get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),
@@ -461,6 +461,8 @@ export type ExecutionRecord = {
461
461
  id: string
462
462
  /** The session this execution ran in; set once the session is really started. */
463
463
  sessionId?: string
464
+ /** Effective configuration of a scheduled session; used only for compatible reuse. */
465
+ sessionReuseKey?: string
464
466
  /** Trigger: manual button or the host scheduler. */
465
467
  trigger: 'manual' | 'scheduled'
466
468
  startedAt?: number
@@ -6,4 +6,5 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.7.0'
9
+ export const PLUGIN_VERSION = '0.7.1'
10
+