dsh-taskboard 0.1.1 → 0.2.0

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.
@@ -59,11 +59,106 @@ function createEntry(controller: BoardController): HTMLButtonElement {
59
59
  entry.dataset.dshAtbEntry = ''
60
60
  entry.className = 'dsh-atb-entry'
61
61
  entry.setAttribute('aria-label', 'Agent 任务看板')
62
- entry.innerHTML = `<span class="dsh-atb-entry-icon">${ICON}</span><span class="dsh-atb-entry-label">任务看板</span>`
62
+ entry.innerHTML = `<span class="dsh-atb-entry-icon">${ICON}</span><span class="dsh-atb-entry-label">任务看板</span><span class="dsh-atb-entry-stats"></span>`
63
63
  entry.addEventListener('click', () => { controller.toggleBoard() })
64
64
  return entry
65
65
  }
66
66
 
67
+ /**
68
+ * Live status counts shown at the right of the entry row:
69
+ * `[todo, in_progress, in_review]` (trashed tasks excluded).
70
+ */
71
+ function entryStats(controller: BoardController): [number, number, number] {
72
+ let todo = 0
73
+ let inProgress = 0
74
+ let inReview = 0
75
+ for (const task of controller.getSnapshot().ledger.tasks) {
76
+ if (task.trashedAt !== undefined) continue
77
+ if (task.status === 'todo') todo++
78
+ else if (task.status === 'in_progress') inProgress++
79
+ else if (task.status === 'in_review') inReview++
80
+ }
81
+ return [todo, inProgress, inReview]
82
+ }
83
+
84
+ /**
85
+ * Set one rolling-number slot. Unchanged values no-op; changes animate the
86
+ * old value out and the new value in with a vertical scroll (up when the
87
+ * count grows, down when it shrinks). Plain DOM, no React.
88
+ */
89
+ function setRollValue(slot: HTMLElement, value: number): void {
90
+ const text = String(value)
91
+ if (slot.dataset.value === text) return
92
+ const previous = slot.dataset.value
93
+ slot.dataset.value = text
94
+ slot.style.minWidth = `${text.length}ch`
95
+ // First render (no previous value): plain text, no animation.
96
+ if (previous === undefined) {
97
+ slot.textContent = text
98
+ return
99
+ }
100
+ // Finalize any in-flight animation before starting the next one.
101
+ if (slot.dataset.busy === '1') {
102
+ slot.dataset.busy = ''
103
+ slot.dataset.anim = ''
104
+ }
105
+ const oldEl = document.createElement('span')
106
+ oldEl.className = 'dsh-atb-rn'
107
+ oldEl.textContent = previous
108
+ const newEl = document.createElement('span')
109
+ newEl.className = 'dsh-atb-rn dsh-atb-rn-next'
110
+ newEl.textContent = text
111
+ slot.replaceChildren(oldEl, newEl)
112
+ // Grow → the strip scrolls up (new enters from below); shrink → down.
113
+ slot.dataset.dir = value > Number(previous) ? 'up' : 'down'
114
+ slot.dataset.busy = '1'
115
+ requestAnimationFrame(() => { slot.dataset.anim = '1' })
116
+ const finish = (): void => {
117
+ if (slot.dataset.busy !== '1') return
118
+ slot.dataset.busy = ''
119
+ slot.dataset.anim = ''
120
+ slot.textContent = slot.dataset.value ?? ''
121
+ }
122
+ slot.addEventListener('transitionend', finish, { once: true })
123
+ // Fallback when transitionend never fires (hidden tab, reduced motion).
124
+ setTimeout(finish, 400)
125
+ }
126
+
127
+ /**
128
+ * Wire the stats strip into the entry: builds the three slots and keeps them
129
+ * (plus the tooltip) in sync with every controller emit.
130
+ * @returns the update function (also called once immediately).
131
+ */
132
+ function wireStats(entry: HTMLButtonElement, controller: BoardController): () => void {
133
+ const stats = entry.querySelector<HTMLElement>('.dsh-atb-entry-stats')
134
+ if (stats === null) return () => {}
135
+ // Slot order = [todo, in_progress, in_review]; each slot carries its status
136
+ // in data-stat so the stylesheet colors the digits (see .dsh-atb-roll).
137
+ const statKeys = ['todo', 'in_progress', 'in_review'] as const
138
+ const slots: HTMLElement[] = []
139
+ for (let i = 0; i < 3; i++) {
140
+ if (i > 0) {
141
+ const sep = document.createElement('span')
142
+ sep.className = 'dsh-atb-entry-sep'
143
+ sep.textContent = '|'
144
+ stats.append(sep)
145
+ }
146
+ const slot = document.createElement('span')
147
+ slot.className = 'dsh-atb-roll'
148
+ slot.dataset.stat = statKeys[i]
149
+ stats.append(slot)
150
+ slots.push(slot)
151
+ }
152
+ const update = (): void => {
153
+ const [todo, inProgress, inReview] = entryStats(controller)
154
+ setRollValue(slots[0]!, todo)
155
+ setRollValue(slots[1]!, inProgress)
156
+ setRollValue(slots[2]!, inReview)
157
+ stats.title = `待办 ${todo} | 进行中 ${inProgress} | 待验收 ${inReview}(待办|进行中|待验收)`
158
+ }
159
+ return update
160
+ }
161
+
67
162
  /** Re-insert the entry after the New Session row (before the browser region). */
68
163
  function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
69
164
  const button = newSessionButton(root)
@@ -146,9 +241,11 @@ export function mountSidebarEntry(controller: BoardController): () => void {
146
241
  // alone; the timer costs one cheap contains-check per tick once placed).
147
242
  const retry = setInterval(() => { tryPlace() }, 2_000)
148
243
 
244
+ const syncStats = wireStats(entry, controller)
149
245
  const syncActive = () => {
150
246
  if (controller.getSnapshot().boardOpen) entry.dataset.active = 'true'
151
247
  else delete entry.dataset.active
248
+ syncStats()
152
249
  }
153
250
  const unsubscribe = controller.subscribe(syncActive)
154
251
  syncActive()
@@ -10,7 +10,7 @@
10
10
  /** The stylesheet text. */
11
11
  export const STYLES = `
12
12
  .dsh-atb-entry {
13
- display: flex; align-items: center; gap: 8px;
13
+ display: flex; align-items: center; gap: 8px; position: relative;
14
14
  width: calc(100% - 8px); margin: 2px 4px; padding: 6px 10px;
15
15
  border: none; border-radius: 8px; background: transparent;
16
16
  color: var(--dsw-text-secondary, inherit); font: inherit; font-size: 13px;
@@ -19,6 +19,35 @@ export const STYLES = `
19
19
  .dsh-atb-entry:hover { background: var(--dsw-hover, rgba(128,128,128,.12)); color: var(--dsw-text-primary, inherit); }
20
20
  .dsh-atb-entry[data-active="true"] { background: var(--dsw-active, rgba(128,128,128,.18)); color: var(--dsw-text-primary, inherit); font-weight: 500; }
21
21
  .dsh-atb-entry svg { flex: none; }
22
+ /* Status strip on the entry row's right: todo|in_progress|in_review counts. */
23
+ .dsh-atb-entry-stats {
24
+ margin-left: auto; display: inline-flex; align-items: center; gap: 3px;
25
+ font-size: 11px; line-height: 1; color: var(--dsw-text-secondary, gray);
26
+ font-variant-numeric: tabular-nums; white-space: nowrap; cursor: help;
27
+ }
28
+ .dsh-atb-entry-sep { opacity: .5; }
29
+ /* Each rolling count wears its status color (todo blue | in_progress orange |
30
+ in_review purple); the separators stay in the strip's neutral gray. */
31
+ .dsh-atb-roll[data-stat="todo"] { color: #3e63dd; }
32
+ .dsh-atb-roll[data-stat="in_progress"] { color: #d9822b; }
33
+ .dsh-atb-roll[data-stat="in_review"] { color: #8e4ec6; }
34
+ /* One rolling number: fixed one-line window, overflow hidden. */
35
+ .dsh-atb-roll {
36
+ position: relative; display: inline-block; overflow: hidden;
37
+ height: 12px; min-width: 1ch; text-align: center; vertical-align: middle;
38
+ }
39
+ .dsh-atb-rn { display: block; height: 12px; line-height: 12px; text-align: center; }
40
+ /* The incoming value sits just outside the window (below for up-scroll). */
41
+ .dsh-atb-rn-next { position: absolute; left: 0; right: 0; top: 100%; }
42
+ .dsh-atb-roll[data-dir="down"] .dsh-atb-rn-next { top: auto; bottom: 100%; }
43
+ .dsh-atb-roll .dsh-atb-rn { transition: transform .3s cubic-bezier(.25, .1, .25, 1); }
44
+ .dsh-atb-roll[data-anim="1"][data-dir="up"] .dsh-atb-rn { transform: translateY(-100%); }
45
+ .dsh-atb-roll[data-anim="1"][data-dir="down"] .dsh-atb-rn { transform: translateY(100%); }
46
+ @media (prefers-reduced-motion: reduce) {
47
+ .dsh-atb-roll .dsh-atb-rn { transition: none; }
48
+ }
49
+ .dsh-atb-search { width: 130px; }
50
+ .dsh-atb-badge[data-kind="stale"] { background: rgba(217,130,43,.15); color: #d9822b; }
22
51
 
23
52
  html[data-dsh-atb-active] [data-pane="conversation"] > *:not([data-dsh-atb-view]) { display: none !important; }
24
53
  .dsh-atb-view { display: none; }
@@ -28,6 +57,16 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
28
57
  .dsh-atb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
29
58
  .dsh-atb-title { font-size: 15px; font-weight: 600; margin: 0; }
30
59
  .dsh-atb-count { font-size: 12px; color: var(--dsw-text-secondary, gray); }
60
+ .dsh-atb-ver {
61
+ font-size: 11px; color: var(--dsw-text-secondary, gray);
62
+ font-variant-numeric: tabular-nums; white-space: nowrap; cursor: pointer;
63
+ text-decoration: none;
64
+ padding: 1px 9px; border-radius: 999px;
65
+ background: var(--dsw-bg-inset, rgba(128,128,128,.1));
66
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.22));
67
+ transition: border-color .12s ease, color .12s ease;
68
+ }
69
+ .dsh-atb-ver:hover { border-color: var(--dsw-border-strong, rgba(128,128,128,.6)); color: inherit; }
31
70
  .dsh-atb-spacer { flex: 1; }
32
71
  .dsh-atb-select, .dsh-atb-input {
33
72
  font: inherit; font-size: 12.5px; padding: 5px 8px; border-radius: 7px;
@@ -48,6 +87,16 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
48
87
  .dsh-atb-dot[data-urgency="urgent"] { background: #e5484d; }
49
88
  .dsh-atb-dot[data-urgency="normal"] { background: #8e4ec6; }
50
89
  .dsh-atb-dot[data-urgency="relaxed"] { background: #3e63dd; }
90
+ /* Status dots (column heads): one fixed color per lifecycle status, matching
91
+ the detail pane's status pills. Canceled/archived share the resting gray;
92
+ trashed (pending purge) keeps the red of the 待清除 badge. */
93
+ .dsh-atb-dot[data-status="backlog"] { background: #8a8f98; }
94
+ .dsh-atb-dot[data-status="todo"] { background: #3e63dd; }
95
+ .dsh-atb-dot[data-status="in_progress"] { background: #d9822b; }
96
+ .dsh-atb-dot[data-status="in_review"] { background: #8e4ec6; }
97
+ .dsh-atb-dot[data-status="done"] { background: #2ea043; }
98
+ .dsh-atb-dot[data-status="canceled"], .dsh-atb-dot[data-status="archived"] { background: #8a8f98; }
99
+ .dsh-atb-dot[data-status="trashed"] { background: #e5484d; }
51
100
 
52
101
  .dsh-atb-btn {
53
102
  font: inherit; font-size: 12.5px; padding: 5px 11px; border-radius: 7px; cursor: pointer;
@@ -172,12 +221,13 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
172
221
  .dsh-atb-desc { white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.55; }
173
222
 
174
223
  .dsh-atb-detail-actions { display: flex; flex-direction: column; gap: 8px; }
175
- .dsh-atb-runbtn {
176
- font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 9px; cursor: pointer;
177
- border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff); text-align: center;
224
+ .dsh-atb-detail-run {
225
+ font: inherit; font-size: 12px; font-weight: 600; padding: 4px 11px; border-radius: 7px; cursor: pointer;
226
+ border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
178
227
  transition: filter .12s ease;
179
228
  }
180
- .dsh-atb-runbtn:hover { filter: brightness(1.1); }
229
+ .dsh-atb-detail-run:hover { filter: brightness(1.1); }
230
+ .dsh-atb-detail-run[data-danger="true"] { background: rgba(229,72,77,.92); }
181
231
  .dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
182
232
  .dsh-atb-movebtn {
183
233
  font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
@@ -256,7 +306,11 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
256
306
  .dsh-atb-exec-outcome[data-outcome="running"] { background: rgba(217,130,43,.15); color: #d9822b; }
257
307
  .dsh-atb-exec-outcome[data-outcome="cancelled"] { background: rgba(128,128,128,.15); color: var(--dsw-text-secondary, gray); }
258
308
  .dsh-atb-exec-time { font-size: 11px; color: var(--dsw-text-secondary, gray); }
259
- .dsh-atb-exec-session { font-size: 11px; color: var(--dsw-text-secondary, gray); }
309
+ .dsh-atb-exec-session {
310
+ font: inherit; font-size: 11px; color: var(--dsw-text-secondary, gray);
311
+ background: none; border: none; padding: 0; cursor: pointer;
312
+ }
313
+ .dsh-atb-exec-session:hover { color: var(--dsw-alias-brand-primary, inherit); text-decoration: underline dotted; }
260
314
  .dsh-atb-exec-error { flex-basis: 100%; font-size: 11px; color: #e5484d; word-break: break-all; }
261
315
 
262
316
  .dsh-atb-dangerzone {
@@ -376,6 +430,29 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
376
430
  .dsh-atb-secondary { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
377
431
  .dsh-atb-link { color: var(--dsw-alias-state-business-primary, #3e63dd); cursor: pointer; text-decoration: none; }
378
432
  .dsh-atb-link:hover { text-decoration: underline; }
433
+
434
+ /* ---------- alert modal ---------- */
435
+ .dsh-atb-alert-backdrop {
436
+ position: fixed; inset: 0; z-index: 90;
437
+ background: var(--dsw-alias-bg-mask-drop, rgba(28,30,36,.4)); backdrop-filter: var(--dsw-mask-blur, blur(2px));
438
+ display: flex; align-items: center; justify-content: center;
439
+ animation: dsh-atb-fade .12s ease;
440
+ }
441
+ .dsh-atb-alert {
442
+ min-width: 280px; max-width: 380px; padding: 20px 24px; border-radius: 14px;
443
+ background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
444
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
445
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
446
+ display: flex; flex-direction: column; align-items: center; gap: 14px;
447
+ animation: dsh-atb-pop .14s ease;
448
+ }
449
+ .dsh-atb-alert-icon { font-size: 28px; line-height: 1; }
450
+ .dsh-atb-alert-msg {
451
+ font-size: 13.5px; line-height: 1.55; text-align: center;
452
+ word-break: break-word; white-space: pre-wrap;
453
+ color: var(--dsw-alias-label-primary, inherit);
454
+ }
455
+ .dsh-atb-alert .dsh-atb-btn { padding: 6px 28px; font-size: 13px; }
379
456
  `
380
457
 
381
458
  let injected = false
@@ -10,10 +10,20 @@
10
10
  *
11
11
  * @module dsh-taskboard/host/execution
12
12
  */
13
- import { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'
13
+ import {
14
+ effectivePrompt,
15
+ newCommentId,
16
+ newExecutionId,
17
+ normalizeBody,
18
+ type ExecutionRecord,
19
+ type TaskRecord,
20
+ } from '../shared/protocol.ts'
14
21
  import { MessageId } from './sdk.ts'
15
22
  import type { TaskStore } from './store.ts'
16
23
 
24
+ /** Default cap on concurrently running executions (env-overridable). */
25
+ export const DEFAULT_MAX_CONCURRENT = 3
26
+
17
27
  /** Narrow agents face (the registry's create, structurally). */
18
28
  export interface AgentsFace {
19
29
  create(options: {
@@ -54,6 +64,10 @@ export interface ExecutionDeps {
54
64
  mintSessionId?: () => string
55
65
  /** Mint message ids (injectable for tests). */
56
66
  mintMessageId?: () => string
67
+ /** Best-effort session rename (pins the session list title to the task title). */
68
+ renameSession?: (sessionId: string, title: string) => void
69
+ /** Max concurrently running executions across all tasks (default 3). */
70
+ maxConcurrent?: number
57
71
  }
58
72
 
59
73
  /** Outcome of a run request (immediate; the run settles asynchronously). */
@@ -61,6 +75,11 @@ export type RunRequestResult =
61
75
  | { ok: true; executionId: string; sessionId: string }
62
76
  | { ok: false; error: string }
63
77
 
78
+ /** Outcome of a cancel request. */
79
+ export type CancelRequestResult =
80
+ | { ok: true; executionId: string }
81
+ | { ok: false; error: string }
82
+
64
83
  /** Whether a turn/end payload closed with an error reason. */
65
84
  function isErrorTurnEnd(data: unknown): { message: string } | undefined {
66
85
  if (typeof data !== 'object' || data === null) return undefined
@@ -76,12 +95,19 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
76
95
  return { message }
77
96
  }
78
97
 
98
+ /** One live execution tracked for settlement and cancellation. */
99
+ interface RunEntry {
100
+ sessionId: string
101
+ settle: () => void
102
+ dispose: () => Promise<void>
103
+ }
104
+
79
105
  /**
80
106
  * The execution service.
81
107
  */
82
108
  export class ExecutionService {
83
- /** Execution ids currently settling. */
84
- private readonly settling = new Map<string, () => void>()
109
+ /** Live executions by execution id (settles and cancels remove entries). */
110
+ private readonly runs = new Map<string, RunEntry>()
85
111
 
86
112
  /** @param deps - store + agents + workspaces + events + clock. */
87
113
  constructor(private readonly deps: ExecutionDeps) {
@@ -92,7 +118,7 @@ export class ExecutionService {
92
118
  })
93
119
  }
94
120
 
95
- /** Record a turn failure against the running execution of that session. */
121
+ /** Record a turn failure against the running execution of that session and give the task back. */
96
122
  private noteFailure(sessionId: string, message: string): void {
97
123
  void this.deps.store.mutate('execution-recorded', (ledger) => {
98
124
  for (const task of ledger.tasks) {
@@ -101,6 +127,21 @@ export class ExecutionService {
101
127
  execution.outcome = 'failed'
102
128
  execution.error = message.slice(0, 500)
103
129
  execution.endedAt = this.deps.now()
130
+ // The failed session will not finish the work: hand the task back
131
+ // instead of leaving it stuck in in_progress forever — and leave a
132
+ // system comment so the GUI shows why.
133
+ if (task.status === 'in_progress' && task.claimedBy === sessionId) {
134
+ task.status = 'todo'
135
+ task.updatedAt = this.deps.now()
136
+ delete task.claimedBy
137
+ delete task.claimedAt
138
+ task.comments.push({
139
+ id: newCommentId(),
140
+ body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
141
+ version: 1,
142
+ createdAt: this.deps.now(),
143
+ })
144
+ }
104
145
  return [task]
105
146
  }
106
147
  }
@@ -125,18 +166,24 @@ export class ExecutionService {
125
166
 
126
167
  /**
127
168
  * Run one task now (manual button or scheduler tick).
169
+ *
170
+ * The in-progress gate and the execution-open write happen inside ONE
171
+ * serial-queue mutation, so two overlapping run() calls (double click,
172
+ * overlapping scheduler ticks) can never both pass — exactly one session
173
+ * is opened per task.
128
174
  * @param taskId - the task to run.
129
175
  * @param trigger - what started it.
130
176
  * @returns the immediate result; settlement lands in the ledger.
131
177
  */
132
178
  async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
179
+ const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
180
+ if (this.runs.size >= max) {
181
+ return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
182
+ }
133
183
  const task = this.deps.store.get(taskId)
134
184
  if (task === undefined || task.trashedAt !== undefined) {
135
185
  return { ok: false, error: `no task ${taskId}` }
136
186
  }
137
- if (task.status === 'in_progress') {
138
- return { ok: false, error: 'task is already in progress' }
139
- }
140
187
  const workspace = this.deps.workspaces.get(task.workspaceId)
141
188
  if (workspace === undefined) {
142
189
  return { ok: false, error: `unknown workspace ${task.workspaceId}` }
@@ -145,10 +192,19 @@ export class ExecutionService {
145
192
  const executionId = newExecutionId()
146
193
  const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
147
194
 
148
- // 1. Open the execution record and move the card to in_progress in one write.
195
+ // 1. Open the execution record, flip the card to in_progress, and record
196
+ // the executing session as the claim holder — atomically.
197
+ let gate: string | undefined
149
198
  await this.deps.store.mutate('execution-recorded', (ledger) => {
150
199
  const target = ledger.tasks.find(t => t.id === taskId)
151
- if (target === undefined) return undefined
200
+ if (target === undefined || target.trashedAt !== undefined) {
201
+ gate = `no task ${taskId}`
202
+ return undefined
203
+ }
204
+ if (target.status === 'in_progress') {
205
+ gate = 'task is already in progress'
206
+ return undefined
207
+ }
152
208
  target.executions.push({
153
209
  id: executionId,
154
210
  trigger,
@@ -158,8 +214,11 @@ export class ExecutionService {
158
214
  target.status = 'in_progress'
159
215
  target.updatedAt = this.deps.now()
160
216
  target.updatedBy = { kind: 'user' }
217
+ target.claimedBy = sessionId
218
+ target.claimedAt = this.deps.now()
161
219
  return [target]
162
220
  })
221
+ if (gate !== undefined) return { ok: false, error: gate }
163
222
 
164
223
  // 2. Create the fresh agent+session inside the task's project, carrying
165
224
  // the pinned model — or the deployment default when unpinned (the
@@ -182,35 +241,66 @@ export class ExecutionService {
182
241
  // 3. Attach the session to the workspace (GUI project session list).
183
242
  await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })
184
243
 
244
+ // 3b. Best-effort rename: pin the session title to the task title so the
245
+ // session list shows the task name (a user-sourced title also stops
246
+ // automatic first-prompt retitling).
247
+ try {
248
+ this.deps.renameSession?.(sessionId, task.title)
249
+ } catch { /* cosmetic */ }
250
+
185
251
  // 4. Record the session id (execution is really started now).
186
252
  await this.patchExecution(executionId, { sessionId })
187
253
 
188
254
  // 5. Submit the effective prompt as an ordinary user message and settle
189
255
  // on quiescence (turn/end errors were already folded by the listener).
256
+ // Source `user` (not `plugin`) so the opening message renders as a
257
+ // normal user bubble in the conversation, exactly like a typed prompt.
190
258
  const message = {
191
259
  id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
192
260
  role: 'user' as const,
193
261
  content: [{ type: 'text' as const, text: this.executionPrompt(task) }],
194
- source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
262
+ source: { kind: 'user' as const },
195
263
  }
196
264
  handle.agent.followup(message)
197
265
 
198
- // 6. Settlement watcher.
266
+ // 6. Settlement watcher: mark succeeded, release the executing session's
267
+ // hold, and — when the session did NOT follow the handoff protocol —
268
+ // auto-move the card to in_review with a system comment (otherwise a
269
+ // disobedient session would leave it hanging in in_progress forever).
199
270
  const settle = (): void => {
200
- this.settling.delete(executionId)
271
+ this.runs.delete(executionId)
201
272
  void this.deps.store.mutate('execution-recorded', (ledger) => {
202
273
  for (const t of ledger.tasks) {
203
274
  const execution = t.executions.find(e => e.id === executionId)
204
275
  if (execution !== undefined && execution.outcome === 'running') {
276
+ const now = this.deps.now()
205
277
  execution.outcome = 'succeeded'
206
- execution.endedAt = this.deps.now()
278
+ execution.endedAt = now
279
+ if (t.status === 'in_progress' && t.claimedBy === sessionId) {
280
+ delete t.claimedBy
281
+ delete t.claimedAt
282
+ }
283
+ if (t.status === 'in_progress') {
284
+ const commented = t.comments.some(c => c.threadId === sessionId)
285
+ t.comments.push({
286
+ id: newCommentId(),
287
+ body: normalizeBody(commented
288
+ ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
289
+ : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
290
+ version: 1,
291
+ createdAt: now,
292
+ })
293
+ t.status = 'in_review'
294
+ t.updatedAt = now
295
+ t.updatedBy = { kind: 'user' }
296
+ }
207
297
  return [t]
208
298
  }
209
299
  }
210
300
  return undefined
211
301
  })
212
302
  }
213
- this.settling.set(executionId, settle)
303
+ this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
214
304
  void handle.agent.whenIdle().then(settle, () => {
215
305
  this.noteFailure(sessionId, 'agent did not reach quiescence')
216
306
  settle()
@@ -219,23 +309,117 @@ export class ExecutionService {
219
309
  return { ok: true, executionId, sessionId }
220
310
  }
221
311
 
222
- /** The prompt text one execution submits (task context + instructions). */
312
+ /** How many executions are currently running (for the concurrency cap). */
313
+ inFlight(): number {
314
+ return this.runs.size
315
+ }
316
+
317
+ /**
318
+ * Cancel the running execution of a task (user action): stop the agent
319
+ * session, mark the execution cancelled, and hand the task back to todo.
320
+ * @param taskId - the task whose execution should be stopped.
321
+ * @returns the immediate result.
322
+ */
323
+ async cancel(taskId: string): Promise<CancelRequestResult> {
324
+ const task = this.deps.store.get(taskId)
325
+ if (task === undefined) return { ok: false, error: `no task ${taskId}` }
326
+ const running = [...task.executions].reverse().find(e => e.outcome === 'running')
327
+ if (running === undefined) return { ok: false, error: 'no running execution' }
328
+
329
+ const entry = this.runs.get(running.id)
330
+ this.runs.delete(running.id)
331
+ // Stop the agent first (best effort): dispose stops the loop, unregisters
332
+ // the agent, and removes its session. A late whenIdle settlement no-ops —
333
+ // the record is no longer 'running'.
334
+ try {
335
+ await entry?.dispose()
336
+ } catch { /* already gone */ }
337
+
338
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
339
+ const target = ledger.tasks.find(t => t.id === taskId)
340
+ if (target === undefined) return undefined
341
+ const execution = target.executions.find(e => e.id === running.id)
342
+ if (execution === undefined || execution.outcome !== 'running') return undefined
343
+ execution.outcome = 'cancelled'
344
+ execution.endedAt = this.deps.now()
345
+ if (target.status === 'in_progress') {
346
+ target.status = 'todo'
347
+ target.updatedAt = this.deps.now()
348
+ delete target.claimedBy
349
+ delete target.claimedAt
350
+ }
351
+ return [target]
352
+ })
353
+ return { ok: true, executionId: running.id }
354
+ }
355
+
356
+ /**
357
+ * Startup reconciliation after a host restart: executions left `running`
358
+ * by the previous process can never settle here (their settlement watchers
359
+ * died with it), so mark them failed and hand their tasks back to todo.
360
+ */
361
+ async reconcile(): Promise<void> {
362
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
363
+ const now = this.deps.now()
364
+ const touched: TaskRecord[] = []
365
+ for (const task of ledger.tasks) {
366
+ let dirty = false
367
+ for (const execution of task.executions) {
368
+ if (execution.outcome === 'running') {
369
+ execution.outcome = 'failed'
370
+ execution.error = 'interrupted by host restart'
371
+ execution.endedAt = now
372
+ dirty = true
373
+ }
374
+ }
375
+ if (!dirty) continue
376
+ if (task.status === 'in_progress') {
377
+ task.status = 'todo'
378
+ task.updatedAt = now
379
+ delete task.claimedBy
380
+ delete task.claimedAt
381
+ }
382
+ touched.push(task)
383
+ }
384
+ return touched.length > 0 ? touched : undefined
385
+ })
386
+ }
387
+
388
+ /**
389
+ * The prompt text one execution submits (task context + instructions).
390
+ * The effective prompt supports two template variables, rendered from the
391
+ * task's own history at submit time (valuable for recurring patrols):
392
+ * `{{lastExecution}}` → the previous execution's trigger/outcome/error;
393
+ * `{{lastComments}}` → the last three comments (who + body).
394
+ */
223
395
  private executionPrompt(task: TaskRecord): string {
224
- const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`
225
396
  const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
226
397
  const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
227
398
  + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
228
399
  + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`
229
- return `${head}\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`
400
+ const base = effectivePrompt(task)
401
+ const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')
402
+ const lastExecText = lastExec === undefined
403
+ ? '(无)'
404
+ : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`
405
+ const lastCommentsText = task.comments.slice(-3)
406
+ .map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)
407
+ .join('\n') || '(无)'
408
+ const body = base
409
+ .replace(/\{\{lastExecution\}\}/g, lastExecText)
410
+ .replace(/\{\{lastComments\}\}/g, lastCommentsText)
411
+ return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${body}\n\n${tail}`
230
412
  }
231
413
 
232
- /** Move a task back out of in_progress after a failed start. */
414
+ /** Move a task back out of in_progress (and release its hold) after a failed start. */
233
415
  private async revertProgress(taskId: string): Promise<void> {
234
416
  await this.deps.store.mutate('execution-recorded', (ledger) => {
235
417
  const target = ledger.tasks.find(t => t.id === taskId)
236
418
  if (target !== undefined && target.status === 'in_progress') {
237
419
  target.status = 'todo'
238
420
  target.updatedAt = this.deps.now()
421
+ delete target.claimedBy
422
+ delete target.claimedAt
239
423
  return [target]
240
424
  }
241
425
  return undefined