@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.
@@ -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
@@ -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
- async launch(task: TaskRecord): Promise<string> {
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
- if (permission !== undefined) {
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 {
@@ -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 sessionId = await this.runner.launch(opened.task)
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
  }