dsh-taskboard 0.7.1 → 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/README.md +2 -2
- package/lib/host/execution.js +94 -47
- package/lib/host/execution.js.map +1 -1
- package/lib/host/scheduled-session.js +50 -0
- package/lib/host/scheduled-session.js.map +1 -0
- package/lib/index.js +12 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +88 -89
- package/src/host/execution.ts +93 -45
- package/src/host/scheduled-session.ts +68 -0
- package/src/index.ts +9 -0
- package/src/shared/protocol.ts +2 -0
package/package.json
CHANGED
|
@@ -1,92 +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.
|
|
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
|
-
}
|
|
92
|
-
|
|
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
|
+
}
|
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
|
*/
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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),
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|