dsh-taskboard 0.4.5 → 0.5.1

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.
Files changed (50) hide show
  1. package/README.md +24 -1
  2. package/lib/client.js +434 -193
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +210 -112
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +128 -97
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +48 -5
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/client/api.ts +26 -8
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/SettingsModal.tsx +84 -0
  28. package/src/client/board/TaskBoard.tsx +47 -40
  29. package/src/client/board/TaskCard.tsx +3 -5
  30. package/src/client/board/TaskDetail.tsx +30 -21
  31. package/src/client/board/TaskFormModal.tsx +39 -31
  32. package/src/client/board/format.ts +26 -0
  33. package/src/client/board/labels.ts +44 -0
  34. package/src/client/controller.ts +86 -34
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +5 -1
  37. package/src/client/styles.ts +4 -0
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +263 -128
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +187 -126
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +11 -2
  48. package/src/shared/protocol.ts +83 -6
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
@@ -8,7 +8,7 @@
8
8
  * @module dsh-taskboard/client/controller
9
9
  */
10
10
  import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
- import type { ChecklistItem, IsolationMode, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
11
+ import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
12
12
  import { emptyLedger } from '../shared/protocol.ts'
13
13
  import type { TaskboardClient } from './api.ts'
14
14
  import type { SessionJumpResult } from './session-jump.ts'
@@ -27,26 +27,6 @@ export type SortBy = 'default' | 'updated' | 'urgency' | 'created'
27
27
  /** localStorage key for persisted view state (filters + sort). */
28
28
  const VIEW_KEY = 'dsh-taskboard-view-v1'
29
29
 
30
- /** localStorage key for the remembered isolation toggle choice (0.3.0). */
31
- const ISOLATION_KEY = 'dsh-taskboard-isolation-v1'
32
-
33
- /** Load the remembered default isolation (worktree unless explicitly turned off). */
34
- export function loadDefaultIsolation(): IsolationMode {
35
- try {
36
- const raw = localStorage.getItem(ISOLATION_KEY)
37
- return raw === 'none' ? 'none' : 'worktree'
38
- } catch {
39
- return 'worktree'
40
- }
41
- }
42
-
43
- /** Remember the isolation toggle choice across forms (best effort). */
44
- export function saveDefaultIsolation(mode: IsolationMode): void {
45
- try {
46
- localStorage.setItem(ISOLATION_KEY, mode)
47
- } catch { /* storage unavailable — choice just won't persist */ }
48
- }
49
-
50
30
  /** Load the persisted view state (never throws; fresh on any parse error). */
51
31
  function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortBy } {
52
32
  try {
@@ -92,6 +72,8 @@ export interface ControllerState {
92
72
  tplManagerOpen: boolean
93
73
  /** Import modal visible (0.4.0). */
94
74
  importOpen: boolean
75
+ /** Board-settings modal visible (0.5.0). */
76
+ settingsOpen: boolean
95
77
  /** Fields a chosen template prefills into the create form (consumed on open). */
96
78
  templatePrefill?: TaskTemplateSpec
97
79
  /** Transient error surface (action failures); cleared on next success. */
@@ -114,6 +96,7 @@ function initialState(): ControllerState {
114
96
  templates: [],
115
97
  tplManagerOpen: false,
116
98
  importOpen: false,
99
+ settingsOpen: false,
117
100
  }
118
101
  }
119
102
 
@@ -126,7 +109,14 @@ export class BoardController {
126
109
  private disposed = false
127
110
  private disposeStream: (() => void) | undefined
128
111
  private refreshInFlight: Promise<void> | undefined
112
+ /** Newest change-frame revision seen on the SSE stream (S16 refresh chase). */
113
+ private seenRevision: number | undefined
129
114
  private sessionJumper: ((sessionId: string) => Promise<SessionJumpResult>) | undefined
115
+ /** Composer catalog faces, installed formally by the client entry (T13). */
116
+ private readonly catalogFaces: {
117
+ models?: () => Promise<Array<{ provider: string; model: string; name?: string }>>
118
+ presets?: () => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }>
119
+ } = {}
130
120
 
131
121
  /** @param client - the route client. */
132
122
  constructor(private readonly client: TaskboardClient) {}
@@ -157,8 +147,10 @@ export class BoardController {
157
147
  void this.refresh()
158
148
  this.disposeStream = this.client.stream(
159
149
  (change: ChangeEvent) => {
160
- this.setState({ ledger: { ...this.state.ledger, revision: change.revision } })
150
+ this.seenRevision = change.revision
161
151
  // Any change invalidates the full snapshot; refetch (cheap, local).
152
+ // No intermediate revision-only setState — the refresh result is the
153
+ // single render a frame produces (review P2: frames rendered twice).
162
154
  void this.refresh()
163
155
  },
164
156
  () => { void this.refresh() },
@@ -170,15 +162,23 @@ export class BoardController {
170
162
  if (this.refreshInFlight !== undefined) return this.refreshInFlight
171
163
  this.refreshInFlight = (async () => {
172
164
  try {
173
- const [ledger, workspaces] = await Promise.all([
174
- this.client.state(),
175
- this.client.workspaces(),
176
- ])
177
- let selected: TaskRecord | undefined
178
- if (this.state.selectedId !== undefined) {
179
- selected = ledger.tasks.find(t => t.id === this.state.selectedId)
165
+ // S16: a change frame landing while a fetch is in flight used to
166
+ // strand the board on a stale snapshot forever (the deduped request
167
+ // predates the newest frame and no further event arrives). Chase the
168
+ // newest seen revision — bounded rounds, then give up until the next
169
+ // frame.
170
+ for (let round = 0; round < 3; round++) {
171
+ const [ledger, workspaces] = await Promise.all([
172
+ this.client.state(),
173
+ this.client.workspaces(),
174
+ ])
175
+ let selected: TaskRecord | undefined
176
+ if (this.state.selectedId !== undefined) {
177
+ selected = ledger.tasks.find(t => t.id === this.state.selectedId)
178
+ }
179
+ this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
180
+ if (this.seenRevision === undefined || ledger.revision >= this.seenRevision) break
180
181
  }
181
- this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
182
182
  } catch (error) {
183
183
  this.setState({ error: error instanceof Error ? error.message : String(error) })
184
184
  } finally {
@@ -277,6 +277,26 @@ export class BoardController {
277
277
  this.sessionJumper = jumper
278
278
  }
279
279
 
280
+ /** T13: formal installers for the composer catalog faces (was a monkeypatch from the client entry). */
281
+ installModelCatalog(fn: () => Promise<Array<{ provider: string; model: string; name?: string }>>): void {
282
+ this.catalogFaces.models = fn
283
+ }
284
+
285
+ /** T13: formal installer for the preset roster face. */
286
+ installPresetRoster(fn: () => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }>): void {
287
+ this.catalogFaces.presets = fn
288
+ }
289
+
290
+ /** The installed model catalog face, when the runtime provides one. */
291
+ get modelCatalog(): (() => Promise<Array<{ provider: string; model: string; name?: string }>>) | undefined {
292
+ return this.catalogFaces.models
293
+ }
294
+
295
+ /** The installed preset roster face, when the runtime provides one. */
296
+ get presetCatalog(): (() => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }>) | undefined {
297
+ return this.catalogFaces.presets
298
+ }
299
+
280
300
  /**
281
301
  * Jump to an execution's session (open it in the GUI). On success the board
282
302
  * closes so the conversation shows; a deleted-or-archived session reports
@@ -390,13 +410,18 @@ export class BoardController {
390
410
  }
391
411
  }
392
412
 
393
- /** Append a user comment. */
394
- async comment(id: string, body: string): Promise<void> {
413
+ /**
414
+ * Append a user comment. Returns whether it landed — the composer keeps its
415
+ * text on failure (T13: it used to clear unconditionally and lose the draft).
416
+ */
417
+ async comment(id: string, body: string): Promise<boolean> {
395
418
  try {
396
419
  await this.client.comment(id, body)
397
420
  await this.refresh()
421
+ return true
398
422
  } catch (error) {
399
423
  this.setState({ error: error instanceof Error ? error.message : String(error) })
424
+ return false
400
425
  }
401
426
  }
402
427
 
@@ -459,6 +484,28 @@ export class BoardController {
459
484
  /** Close the ⚙ diagnostics panel. */
460
485
  closeDiagnostics(): void { this.setState({ diagOpen: false }) }
461
486
 
487
+ /** Open the board-settings modal (0.5.0). */
488
+ openSettings(): void { this.setState({ settingsOpen: true }) }
489
+
490
+ /** Close the board-settings modal. */
491
+ closeSettings(): void { this.setState({ settingsOpen: false }) }
492
+
493
+ /**
494
+ * Replace board settings (0.5.0). The host broadcasts a settings-updated
495
+ * frame; refresh() pulls ledger.settings so every open view follows.
496
+ * @returns whether the write succeeded.
497
+ */
498
+ async updateSettings(body: Parameters<TaskboardClient['updateSettings']>[0]): Promise<boolean> {
499
+ try {
500
+ await this.client.updateSettings(body)
501
+ await this.refresh()
502
+ return true
503
+ } catch (error) {
504
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
505
+ return false
506
+ }
507
+ }
508
+
462
509
  /** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
463
510
  async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
464
511
  try {
@@ -485,7 +532,8 @@ export class BoardController {
485
532
  async duplicate(task: TaskRecord): Promise<void> {
486
533
  try {
487
534
  await this.client.create({
488
- title: `${task.title}(副本)`,
535
+ // Keep the suffix under the host's 200-char title cap (review P1).
536
+ title: `${task.title.slice(0, 196)}(副本)`,
489
537
  workspaceId: task.workspaceId,
490
538
  urgency: task.urgency,
491
539
  description: task.description.length > 0 ? task.description : undefined,
@@ -615,7 +663,11 @@ export class BoardController {
615
663
  /** Download the task list as a CSV (BOM-prefixed for Excel + Chinese text). */
616
664
  exportCsv(): void {
617
665
  const esc = (v: unknown): string => {
618
- const s = String(v ?? '')
666
+ let s = String(v ?? '')
667
+ // S17: formula-injection guard — title/description are agent-controllable
668
+ // and a cell starting with = + - @ would be EXECUTED as a formula by
669
+ // Excel; neutralize with a leading apostrophe.
670
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`
619
671
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
620
672
  }
621
673
  const header = ['id', 'title', 'status', 'urgency', 'blocked', 'project', 'claimedBy', 'mode', 'cron', 'nextRunAt', 'model', 'createdAt', 'updatedAt', 'comments', 'executions']
@@ -52,11 +52,13 @@ export function apply(ctx: ClientContextFace): void {
52
52
  const client = createClient()
53
53
  const controller = new BoardController(client)
54
54
 
55
- // Model catalog for the composer: llm.models over the connection RPC.
55
+ // Model catalog for the composer: llm.models over the connection RPC
56
+ // installed through the controller's formal installer (T13: no more
57
+ // monkeypatched instance properties).
56
58
  const connection = ctx.get?.('connection') as ConnectionFace | undefined
57
59
  if (connection !== undefined) {
58
60
  type CatalogRow = { provider: string; model: string; name?: string }
59
- ;(controller as unknown as { modelCatalog?: () => Promise<CatalogRow[]> }).modelCatalog = async (): Promise<CatalogRow[]> => {
61
+ controller.installModelCatalog(async (): Promise<CatalogRow[]> => {
60
62
  const response = await connection.api.llm.models({})
61
63
  if (!response.result.ok) return []
62
64
  const out: CatalogRow[] = []
@@ -66,13 +68,13 @@ export function apply(ctx: ClientContextFace): void {
66
68
  }
67
69
  }
68
70
  return out
69
- }
71
+ })
70
72
 
71
73
  // Preset roster for the composer (0.3.3): agentPreset.list over the
72
74
  // connection RPC — [{id, name}] plus which one is the deployment
73
75
  // default (the form pre-selects it on create).
74
76
  type PresetRow = { id: string; name?: string }
75
- ;(controller as unknown as { presetCatalog?: () => Promise<{ presets: PresetRow[]; defaultId?: string }> }).presetCatalog = async (): Promise<{ presets: PresetRow[]; defaultId?: string }> => {
77
+ controller.installPresetRoster(async (): Promise<{ presets: PresetRow[]; defaultId?: string }> => {
76
78
  const list = connection.api.agentPresets
77
79
  if (list === undefined) return { presets: [] }
78
80
  const response = await list.list({})
@@ -80,7 +82,7 @@ export function apply(ctx: ClientContextFace): void {
80
82
  const presets = response.result.value.presets.map((p: { id: string; name?: string }) => ({ id: p.id, name: p.name }))
81
83
  const def = response.result.value.presets.find((p: { id: string; isDefault: boolean }) => p.isDefault)
82
84
  return { presets, ...(def !== undefined ? { defaultId: def.id } : {}) }
83
- }
85
+ })
84
86
  }
85
87
 
86
88
  // Session navigation for execution rows: resolved LAZILY on every jump —
@@ -196,7 +196,11 @@ interface AtbDebug { attempts: number; found: boolean; placed: boolean }
196
196
  export function mountSidebarEntry(controller: BoardController): () => void {
197
197
  const entry = createEntry(controller)
198
198
  const debug: AtbDebug = { attempts: 0, found: false, placed: false }
199
- ;(window as unknown as { __atbDebug?: AtbDebug }).__atbDebug = debug
199
+ // Debug handle only where the GUI runs locally; never on remote origins.
200
+ const host = globalThis.location?.hostname
201
+ if (host === 'localhost' || host === '127.0.0.1') {
202
+ ;(window as unknown as { __atbDebug?: AtbDebug }).__atbDebug = debug
203
+ }
200
204
  let root: HTMLElement | undefined
201
205
  let placed = false
202
206
 
@@ -689,6 +689,10 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
689
689
  .dsh-atb-imp-row-status { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); flex-shrink: 0; }
690
690
  .dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
691
691
  .dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
692
+ /* ---------- 0.5.0 board settings ---------- */
693
+ .dsh-atb-set { max-width: 460px; width: min(460px, 92vw); }
694
+ .dsh-atb-set .dsh-atb-mode-picker { margin-top: 8px; }
695
+ .dsh-atb-set .dsh-atb-isolation-note { margin-top: 10px; }
692
696
  `
693
697
 
694
698
  /** Style element id (stable since 0.1.x: hook for tests and debugging). */
@@ -119,10 +119,8 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
119
119
  const kind = (reason as { kind?: unknown }).kind
120
120
  if (kind !== 'error') return undefined
121
121
  const error = (reason as { error?: { message?: unknown } }).error
122
- const detail = JSON.stringify(error) ?? ''
123
122
  const message = typeof error?.message === 'string' ? error.message : 'turn failed'
124
- console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))
125
- void detail
123
+ console.error('[dsh-taskboard] turn error detail:', JSON.stringify(error)?.slice(0, 2000) ?? '')
126
124
  return { message }
127
125
  }
128
126
 
@@ -161,15 +159,32 @@ export class ExecutionService {
161
159
  /** Live executions by execution id (settles and cancels remove entries). */
162
160
  private readonly runs = new Map<string, RunEntry>()
163
161
 
162
+ /** Detaches the turn/end listener (plugin teardown — review P1). */
163
+ private readonly unsubscribeEvents: () => void
164
+
164
165
  /** @param deps - store + agents + workspaces + events + clock. */
165
166
  constructor(private readonly deps: ExecutionDeps) {
166
- deps.events.onSessionEvent((sessionId, event) => {
167
+ this.unsubscribeEvents = deps.events.onSessionEvent((sessionId, event) => {
167
168
  if (event.type !== 'turn/end') return
169
+ // S7 (open question): ANY turn/end with an error reason fails the whole
170
+ // execution and hands the task back. Whether the DSH session loop can
171
+ // produce recoverable per-turn errors (and keep the session alive) needs
172
+ // host-side confirmation; if it can, this should count consecutive
173
+ // errors or wait for an explicit termination signal instead.
168
174
  const failure = isErrorTurnEnd(event.data)
169
- if (failure !== undefined) this.noteFailure(sessionId, failure.message)
175
+ if (failure !== undefined) {
176
+ this.noteFailure(sessionId, failure.message).catch(error => {
177
+ console.error('[dsh-taskboard] failure settlement error:', error)
178
+ })
179
+ }
170
180
  })
171
181
  }
172
182
 
183
+ /** Detach the settlement listener; safe to call once at plugin teardown. */
184
+ dispose(): void {
185
+ this.unsubscribeEvents()
186
+ }
187
+
173
188
  /**
174
189
  * Best-effort evidence collection for a prepared run (fail-soft: undefined
175
190
  * on any git problem — settlement NEVER blocks on git).
@@ -195,13 +210,19 @@ export class ExecutionService {
195
210
  if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat
196
211
  }
197
212
 
198
- /** Record a turn failure against the running execution of that session and give the task back. */
199
- private noteFailure(sessionId: string, message: string): void {
213
+ /**
214
+ * Record a turn failure against the running execution of that session and
215
+ * give the task back. Resolves once the failure settlement has COMMITTED —
216
+ * R2: the whenIdle rejection path awaits this (and only this) before
217
+ * releasing its run entry, so a success settlement can never race it into
218
+ * the ledger and record a failed run as succeeded.
219
+ */
220
+ private noteFailure(sessionId: string, message: string): Promise<void> {
200
221
  // The failed session may already have committed work — collect the
201
222
  // evidence (best effort) BEFORE marking the execution failed (0.3.1).
202
223
  const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
203
- void this.collectEvidence(entry?.prepared).then(facts => {
204
- void this.deps.store.mutate('execution-recorded', (ledger) => {
224
+ return this.collectEvidence(entry?.prepared).then(facts =>
225
+ this.deps.store.mutate('execution-recorded', (ledger) => {
205
226
  for (const task of ledger.tasks) {
206
227
  for (const execution of task.executions) {
207
228
  if (execution.sessionId === sessionId && execution.outcome === 'running') {
@@ -229,16 +250,22 @@ export class ExecutionService {
229
250
  }
230
251
  }
231
252
  return undefined
232
- })
233
- })
253
+ }),
254
+ ).then(() => { /* failure settlement committed */ })
234
255
  }
235
256
 
236
- /** Patch one task's execution record in the ledger. */
257
+ /**
258
+ * Patch one task's execution record in the ledger. R3 depth: a record that
259
+ * already settled (cancelled/failed/succeeded) is never resurrected — the
260
+ * startup path patches sessionId long after the gate opened, and a cancel
261
+ * may have committed in between.
262
+ */
237
263
  private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {
238
264
  await this.deps.store.mutate('execution-recorded', (ledger) => {
239
265
  for (const task of ledger.tasks) {
240
266
  const execution = task.executions.find(e => e.id === executionId)
241
267
  if (execution !== undefined) {
268
+ if (execution.outcome !== 'running') return undefined
242
269
  Object.assign(execution, patch)
243
270
  return [task]
244
271
  }
@@ -296,6 +323,14 @@ export class ExecutionService {
296
323
  gate = 'task is already in progress'
297
324
  return undefined
298
325
  }
326
+ // S4: authoritative capacity check INSIDE the gate — counts ledger-wide
327
+ // running executions, immune to the startup window (`runs` registers
328
+ // only after agent creation, seconds later).
329
+ const running = ledger.tasks.reduce((n, t) => n + t.executions.filter(e => e.outcome === 'running').length, 0)
330
+ if (running >= max) {
331
+ gate = `execution concurrency limit reached (${running}/${max} running)`
332
+ return undefined
333
+ }
299
334
  target.executions.push({
300
335
  id: executionId,
301
336
  trigger,
@@ -305,7 +340,7 @@ export class ExecutionService {
305
340
  })
306
341
  target.status = 'in_progress'
307
342
  target.updatedAt = this.deps.now()
308
- target.updatedBy = { kind: 'user' }
343
+ target.updatedBy = { kind: 'system' }
309
344
  target.claimedBy = sessionId
310
345
  target.claimedAt = this.deps.now()
311
346
  return [target]
@@ -354,6 +389,10 @@ export class ExecutionService {
354
389
  const message = error instanceof Error ? error.message : String(error)
355
390
  await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })
356
391
  await this.revertProgress(taskId)
392
+ // S1: a run that never started must not leave its worktree behind.
393
+ if (prepared !== undefined && this.deps.git !== undefined) {
394
+ try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort (dirty worktrees are kept) */ }
395
+ }
357
396
  return { ok: false, error: `preset composition failed: ${message}` }
358
397
  }
359
398
  let handle: Awaited<ReturnType<AgentsFace['create']>>
@@ -372,9 +411,31 @@ export class ExecutionService {
372
411
  const message = error instanceof Error ? error.message : String(error)
373
412
  await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })
374
413
  await this.revertProgress(taskId)
414
+ // S1: a run that never started must not leave its worktree behind.
415
+ if (prepared !== undefined && this.deps.git !== undefined) {
416
+ try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort (dirty worktrees are kept) */ }
417
+ }
375
418
  return { ok: false, error: message }
376
419
  }
377
420
 
421
+ // R3: the startup path above awaited seconds of git + agent work. A
422
+ // cancel() that landed inside that window already settled the execution
423
+ // (cancelled + task back to todo) — with nothing registered in `runs`,
424
+ // it could not dispose the agent this path was about to create. Re-verify
425
+ // INSIDE the queue (after any enqueued cancel committed) BEFORE injecting:
426
+ // a cancelled card must not gain a zombie session that burns tokens and
427
+ // edits files while the task sits in todo, re-runnable by anyone.
428
+ const stillRunning = await this.deps.store.read(ledger =>
429
+ ledger.tasks.some(t => t.executions.some(e => e.id === executionId && e.outcome === 'running')))
430
+ if (!stillRunning) {
431
+ await handle.dispose().catch(() => { /* best effort */ })
432
+ // S1: do not leave the startup artifacts behind a cancelled run either.
433
+ if (prepared !== undefined && this.deps.git !== undefined) {
434
+ try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort */ }
435
+ }
436
+ return { ok: false, error: 'cancelled during startup' }
437
+ }
438
+
378
439
  // 3. Attach the session to the workspace (GUI project session list).
379
440
  await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })
380
441
 
@@ -418,9 +479,16 @@ export class ExecutionService {
418
479
  void this.settleExecution(executionId, sessionId, prepared)
419
480
  }
420
481
  this.runs.set(executionId, { sessionId, ...(prepared !== undefined ? { prepared } : {}), settle, dispose: () => handle.dispose() })
482
+ // R2: the rejection path owns its state transition EXCLUSIVELY — the old
483
+ // code also called settle() here, racing two evidence collections whose
484
+ // mutations both checked outcome === 'running': whoever committed first
485
+ // won, so a run that never reached quiescence could be recorded as
486
+ // succeeded (and auto-moved to in_review). Now only the failure
487
+ // settlement writes, and the run entry is released after it commits.
421
488
  void handle.agent.whenIdle().then(settle, () => {
422
489
  this.noteFailure(sessionId, 'agent did not reach quiescence')
423
- settle()
490
+ .then(() => { this.runs.delete(executionId) })
491
+ .catch(() => { this.runs.delete(executionId) })
424
492
  })
425
493
 
426
494
  return { ok: true, executionId, sessionId }
@@ -461,7 +529,7 @@ export class ExecutionService {
461
529
  })
462
530
  t.status = 'in_review'
463
531
  t.updatedAt = now
464
- t.updatedBy = { kind: 'user' }
532
+ t.updatedBy = { kind: 'system' }
465
533
  }
466
534
  return [t]
467
535
  }
@@ -554,11 +622,13 @@ export class ExecutionService {
554
622
  // The cancelled session may already have committed work — keep the
555
623
  // evidence (best effort) so the user can inspect or 续跑 (0.3.1).
556
624
  const facts = await this.collectEvidence(entry?.prepared)
625
+ let settled = false
557
626
  await this.deps.store.mutate('execution-recorded', (ledger) => {
558
627
  const target = ledger.tasks.find(t => t.id === taskId)
559
628
  if (target === undefined) return undefined
560
629
  const execution = target.executions.find(e => e.id === running.id)
561
630
  if (execution === undefined || execution.outcome !== 'running') return undefined
631
+ settled = true
562
632
  execution.outcome = 'cancelled'
563
633
  execution.endedAt = this.deps.now()
564
634
  this.applyFacts(execution, facts)
@@ -570,6 +640,10 @@ export class ExecutionService {
570
640
  }
571
641
  return [target]
572
642
  })
643
+ // The execution may have settled (succeeded/failed) between the stale
644
+ // read above and this mutation — a no-op cancel must NOT report success
645
+ // (the GUI used to show 取消成功 for an already-succeeded run, review P1).
646
+ if (!settled) return { ok: false, error: 'execution already settled' }
573
647
  return { ok: true, executionId: running.id }
574
648
  }
575
649
 
@@ -656,7 +730,7 @@ export class ExecutionService {
656
730
  const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')
657
731
  const lastExecText = lastExec === undefined
658
732
  ? '(无)'
659
- : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`
733
+ : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${lastExec.startedAt !== undefined ? new Date(lastExec.startedAt).toISOString() : '?'}`
660
734
  const lastCommentsText = task.comments.slice(-3)
661
735
  .map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)
662
736
  .join('\n') || '(无)'
package/src/host/git.ts CHANGED
@@ -22,7 +22,8 @@
22
22
  *
23
23
  * @module dsh-taskboard/host/git
24
24
  */
25
- import type { CommitInfo } from '../shared/protocol.ts'
25
+ import { resolve } from 'node:path'
26
+ import { isValidTaskId, type CommitInfo } from '../shared/protocol.ts'
26
27
 
27
28
  /** Timeout for quick read-only queries (rev-parse / status / log / diff). */
28
29
  const QUICK_TIMEOUT_MS = 2_000
@@ -123,8 +124,12 @@ export interface GitFace {
123
124
  merge(root: string, branch: string): Promise<void>
124
125
  /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
125
126
  isAncestor(root: string, branch: string): Promise<boolean>
126
- /** Remove a worktree; THROWS when it still has uncommitted changes. */
127
- removeWorktree(root: string, worktreePath: string): Promise<void>
127
+ /**
128
+ * Remove a worktree. Resolves 'removed' on success, 'unregistered' when git
129
+ * no longer knows the path (an orphaned directory). THROWS when it still
130
+ * has uncommitted changes, or on any other git failure (readable reason).
131
+ */
132
+ removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>
128
133
  /** Delete a branch; THROWS (e.g. still checked out in a worktree). */
129
134
  deleteBranch(root: string, branch: string): Promise<void>
130
135
  /**
@@ -162,8 +167,16 @@ export function sanitizeBranchName(title: string, taskId: string): string {
162
167
  return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`
163
168
  }
164
169
 
165
- /** The canonical worktree path of a task inside its workspace (forward slashes). */
170
+ /**
171
+ * The canonical worktree path of a task inside its workspace (forward
172
+ * slashes). R4②: the id is validated HERE so every present and future call
173
+ * site is covered — a traversal-shaped id must never ride into a filesystem
174
+ * path (the cleanup/purge flows `rm -rf` what this returns).
175
+ */
166
176
  export function worktreePathOf(workspacePath: string, taskId: string): string {
177
+ if (!isValidTaskId(taskId)) {
178
+ throw new Error(`Error: invalid_input: illegal task id ${JSON.stringify(taskId.slice(0, 40))}`)
179
+ }
167
180
  const root = workspacePath.replace(/[\\/]+$/, '').replaceAll('\\', '/')
168
181
  return `${root}/${WORKTREE_DIR}/${taskId}`
169
182
  }
@@ -219,10 +232,15 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
219
232
  // worktree's own HEAD so evidence covers only the new run.
220
233
  if (mode === 'reuse') {
221
234
  const wtHead = await quick(['rev-parse', 'HEAD'], path)
222
- if (wtHead.ok && wtHead.stdout.trim().length > 0) {
235
+ // S14: a readable HEAD is not enough — the worktree must be on OUR
236
+ // branch, otherwise a user-created repo at the path would be silently
237
+ // taken over. Foreign or detached → fall through to fresh preparation.
238
+ const wtBranch = wtHead.ok ? await quick(['rev-parse', '--abbrev-ref', 'HEAD'], path) : undefined
239
+ if (wtHead.ok && wtHead.stdout.trim().length > 0
240
+ && wtBranch !== undefined && wtBranch.ok && wtBranch.stdout.trim() === branch) {
223
241
  return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }
224
242
  }
225
- // No live worktree → fall through to a fresh preparation.
243
+ // No live worktree on our branch → fall through to a fresh preparation.
226
244
  }
227
245
 
228
246
  // Baseline: the main worktree's current HEAD (also validates the repo).
@@ -302,7 +320,8 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
302
320
  return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
303
321
  })
304
322
  if (dirtyLines.length > 0) {
305
- throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)
323
+ // Machine-readable tag: callers classify without parsing zh-CN text.
324
+ throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })
306
325
  }
307
326
  }
308
327
  const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)
@@ -320,14 +339,24 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
320
339
  return r.ok
321
340
  },
322
341
 
323
- removeWorktree: (root, worktreePath) => withRootLock(root, async () => {
342
+ removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
324
343
  const status = await quick(['status', '--porcelain'], worktreePath)
325
344
  if (status.ok && status.stdout.trim().length > 0) {
326
345
  const lines = status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
327
- throw new Error(`worktree ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`)
346
+ // Machine-readable tag: purge flows classify without parsing zh-CN text.
347
+ throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
328
348
  }
329
349
  const removed = await heavy(['worktree', 'remove', worktreePath], root)
330
- if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)
350
+ if (removed.ok) return 'removed'
351
+ // S3: classify the failure WITHOUT parsing git's (localizable) stderr —
352
+ // a path absent from `worktree list` is an unregistered leftover, not
353
+ // an error the caller should relay verbatim.
354
+ const list = await quick(['worktree', 'list', '--porcelain'], root)
355
+ const registered = list.ok && list.stdout.split('\n')
356
+ .some(l => l.startsWith('worktree ')
357
+ && resolve(l.slice('worktree '.length).trim()).toLowerCase() === resolve(worktreePath).toLowerCase())
358
+ if (!registered) return 'unregistered'
359
+ throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)
331
360
  }),
332
361
 
333
362
  deleteBranch: (root, branch) => withRootLock(root, async () => {