@linxin666/dsh-client-ui-task-board 0.3.17 → 0.3.18
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 +103 -34
- 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-runner.d.ts +19 -1
- package/lib/types/host-service.d.ts +7 -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-runner.ts +59 -29
- package/src/host-service.ts +12 -1
- package/src/protocol.ts +6 -2
|
@@ -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-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
|
|
@@ -127,7 +135,8 @@ export class TaskBoardHostService {
|
|
|
127
135
|
|
|
128
136
|
private async launch(opened: OpenedRun): Promise<void> {
|
|
129
137
|
try {
|
|
130
|
-
const
|
|
138
|
+
const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds)
|
|
139
|
+
const sessionId = await this.runner.launch(opened.task, reuseSessionId === undefined ? {} : { reuseSessionId })
|
|
131
140
|
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
132
141
|
} catch (error) {
|
|
133
142
|
if (error instanceof SessionLaunchError) {
|
|
@@ -143,6 +152,7 @@ export class TaskBoardHostService {
|
|
|
143
152
|
const running = await this.runner.listRunning()
|
|
144
153
|
const previous = this.power.snapshot()
|
|
145
154
|
if (!running.known) {
|
|
155
|
+
this.idleSessionIds = undefined
|
|
146
156
|
this.power.updateReasons({
|
|
147
157
|
runningSessions: previous.runningSessions,
|
|
148
158
|
armedSchedules: this.ledger.armedScheduleCount(),
|
|
@@ -150,6 +160,7 @@ export class TaskBoardHostService {
|
|
|
150
160
|
})
|
|
151
161
|
return
|
|
152
162
|
}
|
|
163
|
+
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
153
164
|
// Read after the RPC so executions attached while it was in flight are
|
|
154
165
|
// included in this pass, matching the former full-state snapshot timing.
|
|
155
166
|
const runtime = this.ledger.runtimeView()
|
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
|
}
|