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.
- package/README.md +47 -3
- package/lib/client.js +886 -139
- package/lib/host/execution.js +160 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +30 -3
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +10 -1
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/store.js +31 -18
- package/lib/host/store.js.map +1 -1
- package/lib/host/tools.js +14 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +27 -4
- package/lib/index.js.map +1 -1
- package/lib/shared/protocol.js +53 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +3 -0
- package/src/client/board/AlertModal.tsx +36 -0
- package/src/client/board/TaskBoard.tsx +126 -44
- package/src/client/board/TaskCard.tsx +16 -3
- package/src/client/board/TaskDetail.tsx +93 -14
- package/src/client/board/TaskFormModal.tsx +47 -1
- package/src/client/controller.ts +171 -8
- package/src/client/index.ts +10 -0
- package/src/client/session-jump.ts +93 -0
- package/src/client/sidebar-entry.ts +98 -1
- package/src/client/styles.ts +83 -6
- package/src/host/execution.ts +202 -18
- package/src/host/routes.ts +38 -11
- package/src/host/scheduler.ts +20 -4
- package/src/host/store.ts +34 -13
- package/src/host/tools.ts +30 -8
- package/src/index.ts +37 -3
- package/src/shared/protocol.ts +72 -3
- package/src/shared/version.ts +9 -0
|
@@ -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()
|
package/src/client/styles.ts
CHANGED
|
@@ -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-
|
|
176
|
-
font: inherit; font-size:
|
|
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);
|
|
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-
|
|
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 {
|
|
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
|
package/src/host/execution.ts
CHANGED
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module dsh-taskboard/host/execution
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
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
|
-
/**
|
|
84
|
-
private readonly
|
|
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
|
|
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
|
|
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: '
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|