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.
- package/README.md +24 -1
- package/lib/client.js +434 -193
- package/lib/host/execution.js +80 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +49 -5
- package/lib/host/git.js.map +1 -1
- package/lib/host/routes.js +210 -112
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +50 -28
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/sdk.js +7 -2
- package/lib/host/sdk.js.map +1 -1
- package/lib/host/store.js +41 -8
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +10 -3
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +128 -97
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +48 -5
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +9 -8
- package/src/client/api.ts +26 -8
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/SettingsModal.tsx +84 -0
- package/src/client/board/TaskBoard.tsx +47 -40
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +39 -31
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/controller.ts +86 -34
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +5 -1
- package/src/client/styles.ts +4 -0
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +263 -128
- package/src/host/scheduler.ts +62 -36
- package/src/host/sdk.ts +12 -1
- package/src/host/store.ts +53 -7
- package/src/host/templates.ts +12 -3
- package/src/host/tools.ts +187 -126
- package/src/index.ts +10 -1
- package/src/shared/api.ts +11 -2
- package/src/shared/protocol.ts +83 -6
- package/src/shared/version.ts +1 -1
- package/src/client/board/NewTaskModal.tsx +0 -8
package/src/client/controller.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
/**
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
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']
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/client/styles.ts
CHANGED
|
@@ -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). */
|
package/src/host/execution.ts
CHANGED
|
@@ -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:',
|
|
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)
|
|
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
|
-
/**
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
/**
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
127
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 () => {
|