@linxin666/dsh-client-ui-task-board 0.3.17 → 0.3.19
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.i18n.yaml +2 -2
- package/README.md +3 -2
- package/README.zh.md +3 -2
- package/lib/client.js +100 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +143 -39
- package/lib/types/client/board/NewTaskModal.d.ts +9 -3
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/core/session-reuse.d.ts +22 -0
- package/lib/types/core/tasks.d.ts +10 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +2 -0
- package/lib/types/host-runner.d.ts +19 -1
- package/lib/types/host-service.d.ts +18 -0
- package/package.json +1 -1
- package/src/client/board/NewTaskModal.tsx +58 -14
- package/src/client/board/TaskDetail.tsx +39 -1
- package/src/client/locales.ts +12 -0
- package/src/core/session-reuse.ts +31 -0
- package/src/core/store.ts +2 -0
- package/src/core/tasks.ts +11 -0
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host-ledger.ts +30 -2
- package/src/host-runner.ts +59 -29
- package/src/host-service.ts +46 -4
- package/src/protocol.ts +6 -2
|
@@ -15,6 +15,7 @@ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
|
15
15
|
import css from '../board.module.css'
|
|
16
16
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
17
17
|
import { EditTaskModal } from './EditTaskModal.tsx'
|
|
18
|
+
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
18
19
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
19
20
|
import { STATUS_KEY } from './status-key.ts'
|
|
20
21
|
|
|
@@ -143,6 +144,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
|
|
|
143
144
|
))}
|
|
144
145
|
</select>
|
|
145
146
|
</label>
|
|
147
|
+
<label className={css.scheduleToggle}>
|
|
148
|
+
<input
|
|
149
|
+
type="checkbox"
|
|
150
|
+
checked={task.reuseSession === true}
|
|
151
|
+
disabled={pending}
|
|
152
|
+
onChange={event => { controller.updateTask(task.id, { reuseSession: event.target.checked }) }}
|
|
153
|
+
/>
|
|
154
|
+
<span>{t('exec.reuseSession')}</span>
|
|
155
|
+
</label>
|
|
156
|
+
<p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
|
|
146
157
|
</section>
|
|
147
158
|
)
|
|
148
159
|
}
|
|
@@ -258,12 +269,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
258
269
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
259
270
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
260
271
|
const [showEdit, setShowEdit] = useState(false)
|
|
272
|
+
const [showDuplicate, setShowDuplicate] = useState(false)
|
|
261
273
|
|
|
262
274
|
// Keep the overlay in sync if the task record changes underneath.
|
|
263
275
|
const [latest, setLatest] = useState(task)
|
|
264
276
|
useEffect(() => { setLatest(task) }, [task])
|
|
265
277
|
// A re-used overlay instance must not carry an edit session across tasks.
|
|
266
|
-
useEffect(() => {
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
setShowEdit(false)
|
|
280
|
+
setShowDuplicate(false)
|
|
281
|
+
}, [task.id])
|
|
267
282
|
const current = latest
|
|
268
283
|
const snapshot = controller.getSnapshot()
|
|
269
284
|
const running = current.status === 'running'
|
|
@@ -414,6 +429,17 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
414
429
|
{t('detail.edit')}
|
|
415
430
|
</button>
|
|
416
431
|
)}
|
|
432
|
+
{!archived && (
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
className={css.ghostButton}
|
|
436
|
+
disabled={pending}
|
|
437
|
+
onClick={() => { setShowDuplicate(true) }}
|
|
438
|
+
title={canEditTaskContent(current) ? t('detail.duplicate') : t('detail.duplicateAndEdit')}
|
|
439
|
+
>
|
|
440
|
+
{canEditTaskContent(current) ? t('detail.duplicate') : t('detail.duplicateAndEdit')}
|
|
441
|
+
</button>
|
|
442
|
+
)}
|
|
417
443
|
{!archived && (
|
|
418
444
|
<button
|
|
419
445
|
type="button"
|
|
@@ -485,6 +511,18 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
485
511
|
{showEdit && !archived && canEditTaskContent(current) && (
|
|
486
512
|
<EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
|
|
487
513
|
)}
|
|
514
|
+
|
|
515
|
+
{showDuplicate && !archived && (
|
|
516
|
+
<NewTaskModal
|
|
517
|
+
controller={controller}
|
|
518
|
+
initialTask={current}
|
|
519
|
+
onClose={() => { setShowDuplicate(false) }}
|
|
520
|
+
onDuplicateSuccess={async (sourceId) => {
|
|
521
|
+
await controller.archiveTask(sourceId)
|
|
522
|
+
controller.closeTask()
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
488
526
|
</div>
|
|
489
527
|
)
|
|
490
528
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -60,9 +60,13 @@ export const zh = {
|
|
|
60
60
|
'detail.permissionConfirmed': '已确认权限绑定 · {time}',
|
|
61
61
|
'detail.title': '任务详情',
|
|
62
62
|
'detail.edit': '编辑',
|
|
63
|
+
'detail.duplicate': '复制为新任务',
|
|
64
|
+
'detail.duplicateAndEdit': '修改并新建副本',
|
|
63
65
|
'detail.close': '关闭',
|
|
64
66
|
'edit.title': '编辑任务',
|
|
65
67
|
'edit.save': '保存',
|
|
68
|
+
'new.duplicateTitle': '新建任务(副本)',
|
|
69
|
+
'new.archiveOriginal': '创建后归档原任务',
|
|
66
70
|
'detail.prompt': '执行 Prompt',
|
|
67
71
|
'detail.description': '描述',
|
|
68
72
|
'detail.execution': '执行记录',
|
|
@@ -121,6 +125,8 @@ export const zh = {
|
|
|
121
125
|
'exec.permission.danger-full-access': '完全访问',
|
|
122
126
|
'exec.model.default': '宿主默认(agent-default-model)',
|
|
123
127
|
'exec.model.unknown': '(未知模型/回退默认)',
|
|
128
|
+
'exec.reuseSession': '在同一对话继续',
|
|
129
|
+
'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
|
|
124
130
|
'detail.executionSettings': '执行设置',
|
|
125
131
|
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
126
132
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
@@ -208,9 +214,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
208
214
|
'detail.permissionConfirmed': 'Permission confirmed · {time}',
|
|
209
215
|
'detail.title': 'Task Detail',
|
|
210
216
|
'detail.edit': 'Edit',
|
|
217
|
+
'detail.duplicate': 'Duplicate Task',
|
|
218
|
+
'detail.duplicateAndEdit': 'Edit as New Copy',
|
|
211
219
|
'detail.close': 'Close',
|
|
212
220
|
'edit.title': 'Edit Task',
|
|
213
221
|
'edit.save': 'Save',
|
|
222
|
+
'new.duplicateTitle': 'New Task (Copy)',
|
|
223
|
+
'new.archiveOriginal': 'Archive original task upon creation',
|
|
214
224
|
'detail.prompt': 'Run Prompt',
|
|
215
225
|
'detail.description': 'Description',
|
|
216
226
|
'detail.execution': 'Execution History',
|
|
@@ -269,6 +279,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
269
279
|
'exec.permission.danger-full-access': 'Full Access',
|
|
270
280
|
'exec.model.default': 'Host default (agent-default-model)',
|
|
271
281
|
'exec.model.unknown': ' (unknown / fallback to default)',
|
|
282
|
+
'exec.reuseSession': 'Continue in the same conversation',
|
|
283
|
+
'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
|
|
272
284
|
'detail.executionSettings': 'Execution Settings',
|
|
273
285
|
'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
|
|
274
286
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-reuse rule (issue #1419): decides which task runs continue in the
|
|
3
|
+
* previous execution's conversation. Pure, so the fail-closed conditions stay
|
|
4
|
+
* unit-testable without a gateway.
|
|
5
|
+
*/
|
|
6
|
+
import type { TaskRecord } from './tasks.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pick the session a new execution of this task may continue in, or undefined
|
|
10
|
+
* to mint a fresh conversation. Reuse requires ALL of:
|
|
11
|
+
* - the task opted in (`reuseSession === true`);
|
|
12
|
+
* - the task's newest execution carries a session id and has settled (an open
|
|
13
|
+
* execution would have made the ledger refuse the run in the first place);
|
|
14
|
+
* - the roster is known and that session is present and idle.
|
|
15
|
+
*
|
|
16
|
+
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
17
|
+
* conversation is always safe, prompting into a session we cannot see is not.
|
|
18
|
+
* @param task - the task about to run.
|
|
19
|
+
* @param idleSessionIds - ids the last roster saw as present and not running;
|
|
20
|
+
* undefined when that roster is unknown.
|
|
21
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
22
|
+
*/
|
|
23
|
+
export function reusableSessionId(
|
|
24
|
+
task: TaskRecord,
|
|
25
|
+
idleSessionIds: ReadonlySet<string> | undefined,
|
|
26
|
+
): string | undefined {
|
|
27
|
+
if (task.reuseSession !== true || idleSessionIds === undefined) return undefined
|
|
28
|
+
const last = task.executions[task.executions.length - 1]
|
|
29
|
+
if (last === undefined || last.sessionId === undefined || last.endedAt === undefined) return undefined
|
|
30
|
+
return idleSessionIds.has(last.sessionId) ? last.sessionId : undefined
|
|
31
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -65,6 +65,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
65
65
|
if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
|
|
66
66
|
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
67
67
|
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
68
|
+
if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
|
|
68
69
|
if (!Array.isArray(record.executions)) return false
|
|
69
70
|
for (const execution of record.executions) {
|
|
70
71
|
if (typeof execution !== 'object' || execution === null) return false
|
|
@@ -185,6 +186,7 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
185
186
|
task.mode = normalizeTargetId(row.mode)
|
|
186
187
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
187
188
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
189
|
+
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
188
190
|
task.freeze = normalizeFreeze(row.freeze)
|
|
189
191
|
task.handover = normalizeHandover(row.handover)
|
|
190
192
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
package/src/core/tasks.ts
CHANGED
|
@@ -137,6 +137,14 @@ export interface TaskRecord {
|
|
|
137
137
|
* absent falls back to the host default (agent-default-model).
|
|
138
138
|
*/
|
|
139
139
|
model?: string
|
|
140
|
+
/**
|
|
141
|
+
* Whether later executions continue in the previous execution's session
|
|
142
|
+
* (issue #1419) instead of minting a fresh conversation per run. Absent or
|
|
143
|
+
* false keeps the historical one-session-per-execution behavior; the reuse
|
|
144
|
+
* itself only happens when that session is idle and still present (see
|
|
145
|
+
* {@link reusableSessionId}).
|
|
146
|
+
*/
|
|
147
|
+
reuseSession?: boolean
|
|
140
148
|
/**
|
|
141
149
|
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
142
150
|
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
@@ -192,6 +200,8 @@ export interface NewTaskInput {
|
|
|
192
200
|
permission?: TaskPermission
|
|
193
201
|
/** Optional pinned model for the execution session; absent = host default. */
|
|
194
202
|
model?: string
|
|
203
|
+
/** Reuse the previous execution's session for later runs (issue #1419). */
|
|
204
|
+
reuseSession?: boolean
|
|
195
205
|
/**
|
|
196
206
|
* Optional scheduled-run rule requested at creation time (the new-task
|
|
197
207
|
* dialog): an enable flag plus a 5-field cron expression. The create use
|
|
@@ -279,6 +289,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
279
289
|
mode: normalizeTargetId(input.mode),
|
|
280
290
|
permission: isTaskPermission(input.permission) ? input.permission : undefined,
|
|
281
291
|
model: normalizeTargetId(input.model),
|
|
292
|
+
reuseSession: input.reuseSession === true ? true : undefined,
|
|
282
293
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
283
294
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
284
295
|
}
|
|
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
|
|
|
16
16
|
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
17
17
|
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
18
18
|
*/
|
|
19
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model'>> & {
|
|
19
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession'>> & {
|
|
20
20
|
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
21
|
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
22
|
handover?: TaskHandoverInput | null
|
|
@@ -93,6 +93,9 @@ export function applyUpdateTask(
|
|
|
93
93
|
if (('permission' in patch && patch.permission !== undefined && patch.permission !== task.permission) || 'handover' in patch) {
|
|
94
94
|
next.permissionConfirmedAt = undefined
|
|
95
95
|
}
|
|
96
|
+
// Session reuse is a boolean opt-in; false (or an explicit null from a
|
|
97
|
+
// caller that needs to clear it) returns the task to one session per run.
|
|
98
|
+
if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
|
|
96
99
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
97
100
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
98
101
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
package/src/host-ledger.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
@@ -286,6 +286,7 @@ export class HostTaskLedger {
|
|
|
286
286
|
this.file = join(dir, 'ledger-v2.json')
|
|
287
287
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
288
288
|
this.schedulerFile = join(dir, 'scheduler-v2.json')
|
|
289
|
+
this.cleanStaleTemporaryFiles(dir)
|
|
289
290
|
this.lockFd = this.acquireLock()
|
|
290
291
|
try {
|
|
291
292
|
this.document = this.load(dir)
|
|
@@ -303,6 +304,24 @@ export class HostTaskLedger {
|
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
306
|
|
|
307
|
+
/** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
|
|
308
|
+
private cleanStaleTemporaryFiles(dir: string): void {
|
|
309
|
+
try {
|
|
310
|
+
const entries = readdirSync(dir)
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (entry.includes('.tmp-')) {
|
|
313
|
+
try {
|
|
314
|
+
unlinkSync(join(dir, entry))
|
|
315
|
+
} catch {
|
|
316
|
+
// Best-effort cleanup
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// Directory may not exist yet or cannot be read
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
306
325
|
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
307
326
|
summary(): { revision: number; scheduler: TaskBoardSchedulerSnapshot } {
|
|
308
327
|
const { importedSources: _imports, ...scheduler } = this.document.scheduler
|
|
@@ -443,7 +462,16 @@ export class HostTaskLedger {
|
|
|
443
462
|
// tiny sidecar instead; any other patch still goes through the full
|
|
444
463
|
// atomic commit.
|
|
445
464
|
if (patch.lastTickAt !== undefined && Object.keys(patch).every(key => key === 'lastTickAt')) {
|
|
446
|
-
|
|
465
|
+
try {
|
|
466
|
+
this.writeSchedulerSidecar()
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOSPC') {
|
|
469
|
+
// Disk is full; sidecar persistence fails, but in-memory heartbeat
|
|
470
|
+
// remains updated. Swallow to prevent unhandled log cascade crashes.
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
throw error
|
|
474
|
+
}
|
|
447
475
|
return
|
|
448
476
|
}
|
|
449
477
|
this.commit(false)
|
package/src/host-runner.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
|
|
2
2
|
import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPage, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/types'
|
|
3
3
|
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
|
4
4
|
import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
|
|
5
|
-
import type { TaskRecord } from './core/tasks.ts'
|
|
5
|
+
import type { TaskPermission, TaskRecord } from './core/tasks.ts'
|
|
6
6
|
|
|
7
7
|
/** Host services needed to validate a task's workspace before creating a session. */
|
|
8
8
|
export interface TaskBoardWorkspaceRegistry {
|
|
@@ -171,7 +171,17 @@ export class HostExecutionRunner {
|
|
|
171
171
|
return this.gateway.stream({ namespace, method, args: { request }, ...(signal === undefined ? {} : { signal }) })
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Launch one execution. Without `options.reuseSessionId` a fresh session is
|
|
176
|
+
* created, renamed, pinned, and prompted (the historical contract). With it,
|
|
177
|
+
* the run continues in that existing session (issue #1419): the conversation
|
|
178
|
+
* keeps its title and history, the pinned permission/model are re-asserted so
|
|
179
|
+
* the task's execution contract still holds, and the prompt is queued.
|
|
180
|
+
* @param task - the task to run.
|
|
181
|
+
* @param options - optional session to continue in.
|
|
182
|
+
* @returns the session id the execution runs in.
|
|
183
|
+
*/
|
|
184
|
+
async launch(task: TaskRecord, options: { reuseSessionId?: string } = {}): Promise<string> {
|
|
175
185
|
// A handover bundle overrides the legacy pin fields: the bundle is the
|
|
176
186
|
// authoritative execution triplet for a continuation card (issue #5).
|
|
177
187
|
const workspaceId = task.handover?.workspaceId ?? task.workspaceId
|
|
@@ -189,6 +199,17 @@ export class HostExecutionRunner {
|
|
|
189
199
|
if (preset === undefined) throw new Error('agent preset not found: ' + mode)
|
|
190
200
|
if (preset.broken !== undefined) throw new Error('agent preset is unavailable: ' + preset.broken)
|
|
191
201
|
}
|
|
202
|
+
// The ledger only ever stores ids minted by this runner (or the roster),
|
|
203
|
+
// so the brand is reasserted at this boundary instead of re-deriving it.
|
|
204
|
+
const reused = options.reuseSessionId as ExecutionSessionId | undefined
|
|
205
|
+
if (reused !== undefined) {
|
|
206
|
+
try {
|
|
207
|
+
await this.pinAndPrompt(reused, task, permission)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new SessionLaunchError(reused, error)
|
|
210
|
+
}
|
|
211
|
+
return reused
|
|
212
|
+
}
|
|
192
213
|
const created = await this.invoke('session', 'create', {
|
|
193
214
|
...(workspaceId === undefined ? {} : { workspaceId }),
|
|
194
215
|
...(mode === undefined ? {} : { agentPreset: mode }),
|
|
@@ -196,39 +217,48 @@ export class HostExecutionRunner {
|
|
|
196
217
|
const sessionId = created.sessionId
|
|
197
218
|
try {
|
|
198
219
|
await this.invoke('session', 'rename', { sessionId, title: task.title })
|
|
199
|
-
|
|
200
|
-
if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
|
|
201
|
-
const command = await this.commands.execute(sessionId, '/permission ' + permission, AbortSignal.timeout(30_000))
|
|
202
|
-
if (command === undefined) throw new Error('permission command was not acknowledged')
|
|
203
|
-
if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
|
|
204
|
-
}
|
|
205
|
-
if (task.model !== undefined && task.model.trim() !== '') {
|
|
206
|
-
const rawModel = task.model.trim()
|
|
207
|
-
const slashIdx = rawModel.indexOf('/')
|
|
208
|
-
const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : undefined
|
|
209
|
-
const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel
|
|
210
|
-
try {
|
|
211
|
-
await this.invoke('session', 'selectModel', {
|
|
212
|
-
sessionId,
|
|
213
|
-
...(provider ? { provider } : {}),
|
|
214
|
-
model: modelId,
|
|
215
|
-
})
|
|
216
|
-
} catch (modelError) {
|
|
217
|
-
console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
await this.invoke('session', 'prompt', {
|
|
221
|
-
sessionId,
|
|
222
|
-
requestId: 'task-board-' + crypto.randomUUID(),
|
|
223
|
-
mode: 'queue' as const,
|
|
224
|
-
content: [{ type: 'text' as const, text: promptText(task) }],
|
|
225
|
-
})
|
|
220
|
+
await this.pinAndPrompt(sessionId, task, permission)
|
|
226
221
|
} catch (error) {
|
|
227
222
|
throw new SessionLaunchError(sessionId, error)
|
|
228
223
|
}
|
|
229
224
|
return sessionId
|
|
230
225
|
}
|
|
231
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Re-assert the pinned execution contract on a session and queue the task
|
|
229
|
+
* prompt. Shared by the fresh-session and reuse paths so both apply exactly
|
|
230
|
+
* the same permission/model pins before the prompt.
|
|
231
|
+
*/
|
|
232
|
+
private async pinAndPrompt(sessionId: ExecutionSessionId, task: TaskRecord, permission: TaskPermission | undefined): Promise<void> {
|
|
233
|
+
if (permission !== undefined) {
|
|
234
|
+
if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
|
|
235
|
+
const command = await this.commands.execute(sessionId, '/permission ' + permission, AbortSignal.timeout(30_000))
|
|
236
|
+
if (command === undefined) throw new Error('permission command was not acknowledged')
|
|
237
|
+
if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
|
|
238
|
+
}
|
|
239
|
+
if (task.model !== undefined && task.model.trim() !== '') {
|
|
240
|
+
const rawModel = task.model.trim()
|
|
241
|
+
const slashIdx = rawModel.indexOf('/')
|
|
242
|
+
const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : undefined
|
|
243
|
+
const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel
|
|
244
|
+
try {
|
|
245
|
+
await this.invoke('session', 'selectModel', {
|
|
246
|
+
sessionId,
|
|
247
|
+
...(provider ? { provider } : {}),
|
|
248
|
+
model: modelId,
|
|
249
|
+
})
|
|
250
|
+
} catch (modelError) {
|
|
251
|
+
console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await this.invoke('session', 'prompt', {
|
|
255
|
+
sessionId,
|
|
256
|
+
requestId: 'task-board-' + crypto.randomUUID(),
|
|
257
|
+
mode: 'queue' as const,
|
|
258
|
+
content: [{ type: 'text' as const, text: promptText(task) }],
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
232
262
|
async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
|
|
233
263
|
for (let attempt = 1; ; attempt++) {
|
|
234
264
|
try {
|
package/src/host-service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
|
2
2
|
import { nextRunAtMs } from './core/schedule.ts'
|
|
3
|
+
import { reusableSessionId } from './core/session-reuse.ts'
|
|
3
4
|
import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
|
|
4
5
|
import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
|
|
5
6
|
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
@@ -21,6 +22,13 @@ export class TaskBoardHostService {
|
|
|
21
22
|
private pollInFlight = false
|
|
22
23
|
private tickInFlight = false
|
|
23
24
|
private active = true
|
|
25
|
+
/**
|
|
26
|
+
* Ids the last roster poll saw as present and idle; undefined while the
|
|
27
|
+
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
28
|
+
* evidence, so a launch before the first successful poll mints a fresh
|
|
29
|
+
* conversation instead of prompting into a session it cannot see.
|
|
30
|
+
*/
|
|
31
|
+
private idleSessionIds: ReadonlySet<string> | undefined
|
|
24
32
|
private preventIdleSleep = false
|
|
25
33
|
private lastPowerJson = ''
|
|
26
34
|
private readonly now: () => number
|
|
@@ -37,6 +45,7 @@ export class TaskBoardHostService {
|
|
|
37
45
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
|
|
38
46
|
this.power = options.power ?? new PowerInhibitor()
|
|
39
47
|
this.now = options.now ?? Date.now
|
|
48
|
+
installStreamErrorGuards()
|
|
40
49
|
this.ledger.subscribe(() => {
|
|
41
50
|
this.syncPowerReasons()
|
|
42
51
|
this.emit()
|
|
@@ -127,7 +136,8 @@ export class TaskBoardHostService {
|
|
|
127
136
|
|
|
128
137
|
private async launch(opened: OpenedRun): Promise<void> {
|
|
129
138
|
try {
|
|
130
|
-
const
|
|
139
|
+
const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds)
|
|
140
|
+
const sessionId = await this.runner.launch(opened.task, reuseSessionId === undefined ? {} : { reuseSessionId })
|
|
131
141
|
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
132
142
|
} catch (error) {
|
|
133
143
|
if (error instanceof SessionLaunchError) {
|
|
@@ -143,6 +153,7 @@ export class TaskBoardHostService {
|
|
|
143
153
|
const running = await this.runner.listRunning()
|
|
144
154
|
const previous = this.power.snapshot()
|
|
145
155
|
if (!running.known) {
|
|
156
|
+
this.idleSessionIds = undefined
|
|
146
157
|
this.power.updateReasons({
|
|
147
158
|
runningSessions: previous.runningSessions,
|
|
148
159
|
armedSchedules: this.ledger.armedScheduleCount(),
|
|
@@ -150,6 +161,7 @@ export class TaskBoardHostService {
|
|
|
150
161
|
})
|
|
151
162
|
return
|
|
152
163
|
}
|
|
164
|
+
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
153
165
|
// Read after the RPC so executions attached while it was in flight are
|
|
154
166
|
// included in this pass, matching the former full-state snapshot timing.
|
|
155
167
|
const runtime = this.ledger.runtimeView()
|
|
@@ -203,7 +215,7 @@ export class TaskBoardHostService {
|
|
|
203
215
|
|
|
204
216
|
private scheduleLaunch(opened: OpenedRun): void {
|
|
205
217
|
void this.launch(opened).catch(error => {
|
|
206
|
-
|
|
218
|
+
safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
|
|
207
219
|
})
|
|
208
220
|
}
|
|
209
221
|
|
|
@@ -211,7 +223,7 @@ export class TaskBoardHostService {
|
|
|
211
223
|
if (this.pollInFlight || this.disposed) return
|
|
212
224
|
this.pollInFlight = true
|
|
213
225
|
void this.pollSessions().catch(error => {
|
|
214
|
-
|
|
226
|
+
safeConsoleError('[dsh-task-board] session polling failed', error)
|
|
215
227
|
}).finally(() => { this.pollInFlight = false })
|
|
216
228
|
}
|
|
217
229
|
|
|
@@ -219,7 +231,7 @@ export class TaskBoardHostService {
|
|
|
219
231
|
if (this.tickInFlight || this.disposed) return
|
|
220
232
|
this.tickInFlight = true
|
|
221
233
|
void this.tickSchedule(first).catch(error => {
|
|
222
|
-
|
|
234
|
+
safeConsoleError('[dsh-task-board] scheduler tick failed', error)
|
|
223
235
|
}).finally(() => { this.tickInFlight = false })
|
|
224
236
|
}
|
|
225
237
|
|
|
@@ -237,3 +249,33 @@ export class TaskBoardHostService {
|
|
|
237
249
|
for (const listener of [...this.listeners]) listener()
|
|
238
250
|
}
|
|
239
251
|
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Install stream error listeners on process.stderr and process.stdout so that
|
|
255
|
+
* transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a
|
|
256
|
+
* closed pipe) never emit unhandled 'error' events that kill the Node.js host process.
|
|
257
|
+
*/
|
|
258
|
+
export function installStreamErrorGuards(): void {
|
|
259
|
+
for (const stream of [process.stderr, process.stdout]) {
|
|
260
|
+
if (stream && typeof stream.on === 'function') {
|
|
261
|
+
const hasErrorListener = typeof stream.listenerCount === 'function' && stream.listenerCount('error') > 0
|
|
262
|
+
if (!hasErrorListener) {
|
|
263
|
+
stream.on('error', () => {
|
|
264
|
+
// Swallow write stream errors to keep the host process alive
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Defensively log to console.error without letting stderr write failures
|
|
273
|
+
* (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process.
|
|
274
|
+
*/
|
|
275
|
+
export function safeConsoleError(message: string, ...args: unknown[]): void {
|
|
276
|
+
try {
|
|
277
|
+
console.error(message, ...args)
|
|
278
|
+
} catch {
|
|
279
|
+
// Best-effort stderr write; ignore write errors when stderr stream fails
|
|
280
|
+
}
|
|
281
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -157,6 +157,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
157
157
|
...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
|
|
158
158
|
...(task.mode === undefined ? {} : { mode: task.mode }),
|
|
159
159
|
...(task.permission === undefined ? {} : { permission: task.permission }),
|
|
160
|
+
...(task.reuseSession === undefined ? {} : { reuseSession: task.reuseSession }),
|
|
160
161
|
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
161
162
|
...(task.freeze === undefined ? {} : { freeze: task.freeze }),
|
|
162
163
|
...(task.handover === undefined ? {} : { handover: task.handover }),
|
|
@@ -193,9 +194,10 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
|
|
|
193
194
|
|
|
194
195
|
function createInput(value: unknown): value is NewTaskInput {
|
|
195
196
|
const input = record(value)
|
|
196
|
-
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model'])) return false
|
|
197
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
197
198
|
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
198
199
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
|
|
200
|
+
if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
|
|
199
201
|
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
200
202
|
if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
|
|
201
203
|
if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
|
|
@@ -209,7 +211,9 @@ function createInput(value: unknown): value is NewTaskInput {
|
|
|
209
211
|
|
|
210
212
|
function updatePatch(value: unknown): boolean {
|
|
211
213
|
const patch = record(value)
|
|
212
|
-
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model'])) return false
|
|
214
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
215
|
+
// null (or false) clears the reuse opt-in; only a real boolean is accepted.
|
|
216
|
+
if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
|
|
213
217
|
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
|
|
214
218
|
if (!optionalString(patch[key])) return false
|
|
215
219
|
}
|