@fonlan/dsh-task-kanban 0.1.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/LICENSE +21 -0
- package/README.md +45 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1128 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1514 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/server/git.d.ts +16 -0
- package/lib/types/server/models.d.ts +4 -0
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +84 -0
- package/lib/types/server/settings.d.ts +27 -0
- package/lib/types/server/task-store.d.ts +27 -0
- package/lib/types/server/tools.d.ts +11 -0
- package/lib/types/shared/card.d.ts +86 -0
- package/lib/types/shared/lanes.d.ts +9 -0
- package/lib/types/shared/plan.d.ts +12 -0
- package/package.json +105 -0
- package/src/client/api.ts +42 -0
- package/src/client/board.css +361 -0
- package/src/client/board.tsx +516 -0
- package/src/client/index.tsx +47 -0
- package/src/client/kanban-state.ts +104 -0
- package/src/client/locales.ts +127 -0
- package/src/client/sections.tsx +209 -0
- package/src/client/slot-augment.d.ts +15 -0
- package/src/index.ts +34 -0
- package/src/server/git.ts +68 -0
- package/src/server/models.ts +20 -0
- package/src/server/prompts.ts +54 -0
- package/src/server/rpc.ts +167 -0
- package/src/server/runner.ts +834 -0
- package/src/server/settings.ts +69 -0
- package/src/server/task-store.ts +128 -0
- package/src/server/tools.ts +131 -0
- package/src/shared/card.ts +115 -0
- package/src/shared/lanes.ts +43 -0
- package/src/shared/plan.ts +54 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KanbanRunner: the host-side engine.
|
|
3
|
+
* - WorkerPool: per-workspace single worker, global parallel cap from settings.
|
|
4
|
+
* - RefinementRunner: creates the refinement session (cwd = project dir).
|
|
5
|
+
* - PhaseRunner: git worktree bootstrap + one session per phase, same worktree.
|
|
6
|
+
* - MergeRunner: pure-CLI auto-merge with an AI merge session on conflicts.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto'
|
|
9
|
+
import { appendFile, readFile } from 'node:fs/promises'
|
|
10
|
+
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
|
15
|
+
import type { KanbanCard, ErrorKind, Lane, Plan } from '../shared/card.js'
|
|
16
|
+
import type { TaskStore } from './task-store.js'
|
|
17
|
+
import type { KanbanSettingsFace } from './settings.js'
|
|
18
|
+
import { currentBranch, detectBaseRef, hasStashMessage, isGitRepo, isTreeDirty, revParse, runGit, unmergedPaths } from './git.js'
|
|
19
|
+
import { mergePrompt, phasePrompt, refinementPrompt } from './prompts.js'
|
|
20
|
+
import { registerKanbanTools, type KanbanToolResolver } from './tools.js'
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-agent-presets'
|
|
22
|
+
|
|
23
|
+
const WORKTREES_REL = ['.dsh', 'worktrees']
|
|
24
|
+
|
|
25
|
+
interface WorkspaceEntry {
|
|
26
|
+
title: string
|
|
27
|
+
path: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RunningEntry {
|
|
31
|
+
cardId: string
|
|
32
|
+
workspacePath: string
|
|
33
|
+
stopped: boolean
|
|
34
|
+
cancel: (() => void) | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class KanbanRunner implements KanbanToolResolver {
|
|
38
|
+
private ctx: Context
|
|
39
|
+
private store: TaskStore
|
|
40
|
+
private settings: KanbanSettingsFace
|
|
41
|
+
private runningByWs = new Map<string, RunningEntry>()
|
|
42
|
+
private slots = 0
|
|
43
|
+
private pumpTimer: NodeJS.Timeout | undefined
|
|
44
|
+
/** sessionId → cardId (sessions created for this kanban). */
|
|
45
|
+
private sessionCards = new Map<string, string>()
|
|
46
|
+
/** sessionId → true once the scoped completion tool ran. */
|
|
47
|
+
private completed = new Map<string, boolean>()
|
|
48
|
+
private merging = new Set<string>()
|
|
49
|
+
/** Retained agent handles (kept so ended sessions stay in the store/UI). */
|
|
50
|
+
private handles = new Map<string, AgentHandle[]>()
|
|
51
|
+
|
|
52
|
+
constructor(ctx: Context, store: TaskStore, settings: KanbanSettingsFace) {
|
|
53
|
+
this.ctx = ctx
|
|
54
|
+
this.store = store
|
|
55
|
+
this.settings = settings
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
register(ctx: Context): void {
|
|
59
|
+
this.ctx = ctx
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
start(): void {
|
|
63
|
+
this.pumpTimer = setInterval(() => { void this.pump() }, 2500)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stop(): void {
|
|
67
|
+
if (this.pumpTimer !== undefined) clearInterval(this.pumpTimer)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private newSessionId(): SessionId {
|
|
71
|
+
return `session-${randomUUID()}` as SessionId
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private asUserMessage(text: string) {
|
|
75
|
+
return createUserMessage({
|
|
76
|
+
content: [{ type: 'text', text }],
|
|
77
|
+
// kind must be 'user' (not 'plugin'): the session-title service and the
|
|
78
|
+
// conversation UI only treat `source.kind === 'user'` messages as human
|
|
79
|
+
// input — with 'plugin' the kanban sessions never get an LLM title.
|
|
80
|
+
source: { kind: 'user' },
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async workspaceList(): Promise<WorkspaceEntry[]> {
|
|
85
|
+
const ws = (this.ctx.get('workspace') ?? this.ctx.get('workspaceRegistry')) as { list(): WorkspaceEntry[] } | undefined
|
|
86
|
+
if (ws === undefined) return []
|
|
87
|
+
try {
|
|
88
|
+
return ws.list()
|
|
89
|
+
} catch {
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── KanbanToolResolver ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
cardOfSession(sessionId?: string): string | undefined {
|
|
97
|
+
return sessionId === undefined ? undefined : this.sessionCards.get(sessionId)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async writePlan(cardId: string, plan: Plan): Promise<void> {
|
|
101
|
+
await this.store.mutate(cardId, (card) => {
|
|
102
|
+
card.plan = plan
|
|
103
|
+
card.phaseCount = plan.phases.length
|
|
104
|
+
card.status = 'planned'
|
|
105
|
+
card.error = undefined
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async phaseComplete(sessionId: string, summary: string): Promise<string> {
|
|
110
|
+
const cardId = this.sessionCards.get(sessionId)
|
|
111
|
+
if (cardId === undefined) return ''
|
|
112
|
+
this.completed.set(sessionId, true)
|
|
113
|
+
let phaseId = ''
|
|
114
|
+
await this.store.mutate(cardId, (card) => {
|
|
115
|
+
const attempt = card.sessions.phases.find((a) => a.sessionId === sessionId)
|
|
116
|
+
if (attempt !== undefined) {
|
|
117
|
+
attempt.summary = summary
|
|
118
|
+
attempt.completedAt = Date.now()
|
|
119
|
+
if (card.plan !== undefined && card.plan.phases[attempt.phaseIndex] !== undefined) {
|
|
120
|
+
phaseId = card.plan.phases[attempt.phaseIndex].id
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
return phaseId
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async mergeResolved(sessionId: string): Promise<void> {
|
|
128
|
+
const cardId = this.sessionCards.get(sessionId)
|
|
129
|
+
if (cardId === undefined) return
|
|
130
|
+
this.completed.set(sessionId, true)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private toolResolver(): KanbanToolResolver {
|
|
134
|
+
return {
|
|
135
|
+
cardOfSession: (sid) => this.cardOfSession(sid),
|
|
136
|
+
writePlan: (cardId, plan) => this.writePlan(cardId, plan),
|
|
137
|
+
phaseComplete: (sid, summary) => this.phaseComplete(sid, summary),
|
|
138
|
+
mergeResolved: (sid) => this.mergeResolved(sid),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Restart recovery ──────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
async recoverInterrupted(): Promise<void> {
|
|
145
|
+
for (const ws of await this.workspaceList()) {
|
|
146
|
+
const cards = await this.store.list(ws.path)
|
|
147
|
+
for (const c of cards) {
|
|
148
|
+
if (c.status === 'running' || c.status === 'refining' || c.status === 'merging') {
|
|
149
|
+
const stage: Lane = c.status === 'running' ? 'running' : c.status === 'merging' ? 'completed' : 'demand'
|
|
150
|
+
await this.store.mutate(c.id, (card) => {
|
|
151
|
+
card.status = 'error'
|
|
152
|
+
card.error = { kind: 'interrupted', stage, message: '服务重启导致会话中断,请点击重试继续', at: Date.now() }
|
|
153
|
+
}, ws.path)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Card operations (RPC entry points) ────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
async listCards(workspacePath: string): Promise<KanbanCard[]> {
|
|
162
|
+
return this.store.list(workspacePath)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async createTask(input: { workspacePath: string; requirement: string; model: string; provider?: string }): Promise<KanbanCard> {
|
|
166
|
+
const card = await this.store.create({
|
|
167
|
+
workspacePath: input.workspacePath,
|
|
168
|
+
requirement: input.requirement,
|
|
169
|
+
model: input.model,
|
|
170
|
+
provider: input.provider,
|
|
171
|
+
status: 'refining',
|
|
172
|
+
})
|
|
173
|
+
await this.ensureGitignoreDsh(input.workspacePath)
|
|
174
|
+
void this.runRefinement(card.id, input.workspacePath)
|
|
175
|
+
return card
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async moveTask(cardId: string, toLane: Lane): Promise<{ ok: boolean; message?: string }> {
|
|
179
|
+
const card = await this.findCard(cardId)
|
|
180
|
+
if (card === undefined) return { ok: false, message: '卡片不存在' }
|
|
181
|
+
if (toLane === 'queue') {
|
|
182
|
+
if (card.status === 'running' || card.status === 'refining' || card.status === 'merging') {
|
|
183
|
+
// stop semantics: running card dropped back to queue
|
|
184
|
+
if (card.status === 'running') {
|
|
185
|
+
await this.requestStop(cardId)
|
|
186
|
+
return { ok: true }
|
|
187
|
+
}
|
|
188
|
+
return { ok: false, message: '细化/合并中的卡片不能拖回队列' }
|
|
189
|
+
}
|
|
190
|
+
if (card.status !== 'planned') {
|
|
191
|
+
return { ok: false, message: '只有已规划(有实现计划)的卡片才能进入队列' }
|
|
192
|
+
}
|
|
193
|
+
await this.store.mutate(cardId, (c) => {
|
|
194
|
+
c.status = 'queued'
|
|
195
|
+
c.queuedAt = Date.now()
|
|
196
|
+
c.error = undefined
|
|
197
|
+
}, card.workspacePath)
|
|
198
|
+
void this.pump()
|
|
199
|
+
return { ok: true }
|
|
200
|
+
}
|
|
201
|
+
if (toLane === 'demand') {
|
|
202
|
+
if (card.status !== 'queued') return { ok: false, message: '只有队列中的卡片可以拖回需求区' }
|
|
203
|
+
await this.store.mutate(cardId, (c) => {
|
|
204
|
+
c.status = 'planned'
|
|
205
|
+
c.queuedAt = undefined
|
|
206
|
+
c.error = undefined
|
|
207
|
+
}, card.workspacePath)
|
|
208
|
+
return { ok: true }
|
|
209
|
+
}
|
|
210
|
+
return { ok: false, message: '目标泳道不接受拖拽' }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async stopTask(cardId: string): Promise<{ ok: boolean; message?: string }> {
|
|
214
|
+
const card = await this.findCard(cardId)
|
|
215
|
+
if (card === undefined) return { ok: false, message: '卡片不存在' }
|
|
216
|
+
if (card.status !== 'running' && card.status !== 'merging') {
|
|
217
|
+
return { ok: false, message: '只有运行中的卡片可以停止' }
|
|
218
|
+
}
|
|
219
|
+
if (card.status === 'merging') return { ok: false, message: '合并流程中不能停止' }
|
|
220
|
+
await this.requestStop(cardId)
|
|
221
|
+
return { ok: true }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async retryTask(cardId: string): Promise<{ ok: boolean; message?: string }> {
|
|
225
|
+
const card = await this.findCard(cardId)
|
|
226
|
+
if (card === undefined) return { ok: false, message: '卡片不存在' }
|
|
227
|
+
if (card.status !== 'error') return { ok: false, message: '只有错误状态的卡片可以重试' }
|
|
228
|
+
const kind = card.error?.kind
|
|
229
|
+
if (kind === 'refine_failed' || (kind === 'interrupted' && card.error?.stage === 'demand')) {
|
|
230
|
+
await this.store.mutate(cardId, (c) => { c.status = 'refining'; c.error = undefined }, card.workspacePath)
|
|
231
|
+
void this.runRefinement(cardId, card.workspacePath)
|
|
232
|
+
return { ok: true }
|
|
233
|
+
}
|
|
234
|
+
if (kind === 'merge_failed' || (kind === 'interrupted' && card.error?.stage === 'completed')) {
|
|
235
|
+
await this.store.mutate(cardId, (c) => { c.status = 'completed'; c.error = undefined }, card.workspacePath)
|
|
236
|
+
void this.runMerge(cardId, card.workspacePath)
|
|
237
|
+
return { ok: true }
|
|
238
|
+
}
|
|
239
|
+
// phase_failed / interrupted(running) / no_base_branch / worktree_failed / create_failed: back to queue
|
|
240
|
+
await this.store.mutate(cardId, (c) => {
|
|
241
|
+
c.status = 'queued'
|
|
242
|
+
c.queuedAt = Date.now()
|
|
243
|
+
c.error = undefined
|
|
244
|
+
}, card.workspacePath)
|
|
245
|
+
void this.pump()
|
|
246
|
+
return { ok: true }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async deleteTask(cardId: string): Promise<{ ok: boolean; message?: string }> {
|
|
250
|
+
const card = await this.findCard(cardId)
|
|
251
|
+
if (card === undefined) return { ok: false, message: '卡片不存在' }
|
|
252
|
+
if (card.status === 'running' || card.status === 'refining' || card.status === 'merging') {
|
|
253
|
+
return { ok: false, message: '运行中的卡片必须先停止才能删除' }
|
|
254
|
+
}
|
|
255
|
+
if (card.gitMode && card.worktreePath !== undefined) {
|
|
256
|
+
await runGit(card.workspacePath, ['worktree', 'remove', '--force', card.worktreePath])
|
|
257
|
+
if (card.branch !== undefined) await runGit(card.workspacePath, ['branch', '-D', card.branch])
|
|
258
|
+
}
|
|
259
|
+
for (const sid of card.sessions.refinement) this.sessionCards.delete(sid)
|
|
260
|
+
for (const a of card.sessions.phases) this.sessionCards.delete(a.sessionId)
|
|
261
|
+
for (const sid of card.sessions.merge) this.sessionCards.delete(sid)
|
|
262
|
+
await this.store.remove(card.workspacePath, cardId)
|
|
263
|
+
return { ok: true }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async findCard(cardId: string): Promise<KanbanCard | undefined> {
|
|
267
|
+
for (const ws of await this.workspaceList()) {
|
|
268
|
+
const card = await this.store.get(ws.path, cardId)
|
|
269
|
+
if (card !== undefined) return card
|
|
270
|
+
}
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Worker pool ───────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
private maxParallel(): number {
|
|
277
|
+
return this.settings.get().maxParallelWorkers
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async pump(): Promise<void> {
|
|
281
|
+
if (this.slots >= this.maxParallel()) return
|
|
282
|
+
const workspaces = await this.workspaceList()
|
|
283
|
+
for (const ws of workspaces) {
|
|
284
|
+
if (this.slots >= this.maxParallel()) return
|
|
285
|
+
if (this.runningByWs.has(ws.path)) continue
|
|
286
|
+
const cards = await this.store.list(ws.path)
|
|
287
|
+
const queued = cards
|
|
288
|
+
.filter((c) => c.status === 'queued')
|
|
289
|
+
.sort((a, b) => (a.queuedAt ?? a.createdAt) - (b.queuedAt ?? b.createdAt))
|
|
290
|
+
const next = queued[0]
|
|
291
|
+
if (next === undefined) continue
|
|
292
|
+
this.slots += 1
|
|
293
|
+
const entry: RunningEntry = { cardId: next.id, workspacePath: ws.path, stopped: false, cancel: null }
|
|
294
|
+
this.runningByWs.set(ws.path, entry)
|
|
295
|
+
void this.runCard(ws.path, next, entry).finally(() => {
|
|
296
|
+
this.slots -= 1
|
|
297
|
+
this.runningByWs.delete(ws.path)
|
|
298
|
+
void this.pump()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async requestStop(cardId: string): Promise<void> {
|
|
304
|
+
for (const entry of this.runningByWs.values()) {
|
|
305
|
+
if (entry.cardId === cardId) {
|
|
306
|
+
entry.stopped = true
|
|
307
|
+
entry.cancel?.()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async fail(cardId: string, kind: ErrorKind, message: string, stage?: Lane): Promise<void> {
|
|
314
|
+
await this.store.mutate(cardId, (card) => {
|
|
315
|
+
card.status = 'error'
|
|
316
|
+
card.error = { kind, message, at: Date.now(), ...(stage !== undefined ? { stage } : {}) }
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Refinement ────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
private async runRefinement(cardId: string, wsPath: string): Promise<void> {
|
|
323
|
+
const card = await this.store.get(wsPath, cardId)
|
|
324
|
+
if (card === undefined) return
|
|
325
|
+
const route = await this.modelRoute(cardId, wsPath)
|
|
326
|
+
const sessionId = this.newSessionId()
|
|
327
|
+
this.sessionCards.set(sessionId, cardId)
|
|
328
|
+
await this.store.mutate(cardId, (c) => {
|
|
329
|
+
c.status = 'refining'
|
|
330
|
+
c.error = undefined
|
|
331
|
+
c.sessions.refinement.push(sessionId)
|
|
332
|
+
}, wsPath)
|
|
333
|
+
try {
|
|
334
|
+
const handle = await this.createAgent(sessionId, wsPath, route, this.toolResolver())
|
|
335
|
+
this.keepHandle(cardId, handle)
|
|
336
|
+
await this.attachRefinementSession(wsPath, sessionId)
|
|
337
|
+
handle.agent.followup(this.asUserMessage(refinementPrompt(card.requirement, wsPath)))
|
|
338
|
+
await handle.agent.whenIdle()
|
|
339
|
+
const fresh = await this.store.get(wsPath, cardId)
|
|
340
|
+
if (fresh !== undefined && fresh.status === 'refining') {
|
|
341
|
+
// debug: log the session tail regardless
|
|
342
|
+
try {
|
|
343
|
+
const events = handle.agent.session.events
|
|
344
|
+
const tail = events.slice(-10).map((e) => ({
|
|
345
|
+
type: e.type,
|
|
346
|
+
...(e.data !== undefined ? { data: JSON.stringify(e.data).slice(0, 600) } : {}),
|
|
347
|
+
}))
|
|
348
|
+
console.error('[task-kanban] refinement session tail:', JSON.stringify(tail, null, 1))
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error('[task-kanban] could not read session events:', String(error))
|
|
351
|
+
}
|
|
352
|
+
await this.fail(cardId, 'refine_failed', '细化会话结束但未通过 kanban_write_plan 写回计划', 'demand')
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.error('[task-kanban] refinement create/drive failed:', String(error))
|
|
356
|
+
await this.fail(cardId, 'refine_failed', `细化会话失败: ${String(error)}`, 'demand')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async createAgent(
|
|
361
|
+
sessionId: SessionId,
|
|
362
|
+
cwd: string,
|
|
363
|
+
route: { provider?: string; model?: string },
|
|
364
|
+
resolver: KanbanToolResolver,
|
|
365
|
+
) {
|
|
366
|
+
const agentOptions =
|
|
367
|
+
route.provider !== undefined && route.model !== undefined && route.model !== ''
|
|
368
|
+
? { provider: route.provider, model: route.model }
|
|
369
|
+
: undefined
|
|
370
|
+
return this.ctx.agents.create({
|
|
371
|
+
sessionId,
|
|
372
|
+
meta: { cwd },
|
|
373
|
+
...(agentOptions !== undefined ? { agentOptions } : {}),
|
|
374
|
+
setup: async (agentCtx) => {
|
|
375
|
+
const agentPresets = agentCtx.get('agentPresets') as
|
|
376
|
+
| { mount(agentCtx: unknown, id?: string): Promise<unknown> }
|
|
377
|
+
| undefined
|
|
378
|
+
if (agentPresets !== undefined) {
|
|
379
|
+
await agentPresets.mount(agentCtx, 'standard')
|
|
380
|
+
} else {
|
|
381
|
+
throw new Error('@fonlan/dsh-task-kanban: agent-presets service is not mounted')
|
|
382
|
+
}
|
|
383
|
+
registerKanbanTools(agentCtx, resolver)
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private keepHandle(cardId: string, handle: AgentHandle): void {
|
|
389
|
+
const list = this.handles.get(cardId) ?? []
|
|
390
|
+
list.push(handle)
|
|
391
|
+
this.handles.set(cardId, list)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Account the refinement session to its host workspace so it shows in that
|
|
396
|
+
* workspace's session list. The session's cwd IS the workspace root, so the
|
|
397
|
+
* registry's strict `cwd === workspace.path` check passes. Best-effort only:
|
|
398
|
+
* an unregistered workspace (or a failing registry) must not break the card.
|
|
399
|
+
*/
|
|
400
|
+
private async attachRefinementSession(workspacePath: string, sessionId: SessionId): Promise<void> {
|
|
401
|
+
const registry = this.attachRegistry()
|
|
402
|
+
if (registry === undefined) return
|
|
403
|
+
try {
|
|
404
|
+
const workspace = await registry.resolveByPath(workspacePath)
|
|
405
|
+
if (workspace !== undefined) await workspace.attachSession(sessionId)
|
|
406
|
+
} catch (error) {
|
|
407
|
+
console.error('[task-kanban] attach refinement session to workspace failed:', String(error))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The host workspace registry, whichever service name carries it. */
|
|
412
|
+
private attachRegistry(): { resolveByPath(path: string): Promise<{ attachSession(id: string): Promise<unknown> } | undefined> } | undefined {
|
|
413
|
+
for (const name of ['workspaceRegistry', 'workspace']) {
|
|
414
|
+
const candidate = this.ctx.get(name) as { resolveByPath?: unknown } | undefined
|
|
415
|
+
if (candidate !== undefined && typeof candidate.resolveByPath === 'function') {
|
|
416
|
+
return candidate as { resolveByPath(path: string): Promise<{ attachSession(id: string): Promise<unknown> } | undefined> }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return undefined
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Resolve the provider+model route for an agent (both are required). */
|
|
423
|
+
private async modelRoute(cardId: string, wsPath: string): Promise<{ provider?: string; model?: string }> {
|
|
424
|
+
const card = await this.store.get(wsPath, cardId)
|
|
425
|
+
if (card === undefined) return {}
|
|
426
|
+
if (card.model !== '' && card.provider !== undefined && card.provider !== '') {
|
|
427
|
+
return { provider: card.provider, model: card.model }
|
|
428
|
+
}
|
|
429
|
+
if (card.model !== '') {
|
|
430
|
+
const fallback = this.settings.defaultModelRoute()
|
|
431
|
+
return fallback.provider !== undefined ? { provider: fallback.provider, model: card.model } : { model: card.model }
|
|
432
|
+
}
|
|
433
|
+
const userDefault = this.settings.get().defaultModel
|
|
434
|
+
if (userDefault !== '') {
|
|
435
|
+
const fallback = this.settings.defaultModelRoute()
|
|
436
|
+
return fallback.provider !== undefined ? { provider: fallback.provider, model: userDefault } : { model: userDefault }
|
|
437
|
+
}
|
|
438
|
+
return this.settings.defaultModelRoute()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Phase execution ───────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
private async runCard(wsPath: string, card: KanbanCard, entry: RunningEntry): Promise<void> {
|
|
444
|
+
await this.store.mutate(card.id, (c) => {
|
|
445
|
+
if (c.status === 'queued') {
|
|
446
|
+
c.status = 'running'
|
|
447
|
+
c.error = undefined
|
|
448
|
+
}
|
|
449
|
+
}, wsPath)
|
|
450
|
+
|
|
451
|
+
const gitMode = await isGitRepo(wsPath)
|
|
452
|
+
await this.store.mutate(card.id, (c) => { c.gitMode = gitMode }, wsPath)
|
|
453
|
+
|
|
454
|
+
if (gitMode) {
|
|
455
|
+
const fresh = await this.store.get(wsPath, card.id)
|
|
456
|
+
if (fresh === undefined) return
|
|
457
|
+
if (fresh.worktreePath === undefined || fresh.branch === undefined || fresh.baseRef === undefined) {
|
|
458
|
+
const base = await detectBaseRef(wsPath)
|
|
459
|
+
if (base === undefined) {
|
|
460
|
+
await this.fail(card.id, 'no_base_branch', '项目没有 main 或 master 分支,无法创建 worktree', 'running')
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
const branch = `kanban/${card.id.slice(0, 8)}`
|
|
464
|
+
const wt = join(wsPath, ...WORKTREES_REL, card.id)
|
|
465
|
+
await this.store.mutate(card.id, (c) => { c.gitMode = true }, wsPath)
|
|
466
|
+
const result = await runGit(wsPath, ['worktree', 'add', '-b', branch, wt, base])
|
|
467
|
+
if (result.code !== 0) {
|
|
468
|
+
await this.fail(card.id, 'worktree_failed', `创建 worktree 失败: ${result.stderr}`, 'running')
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
const baseSha = await revParse(wsPath, base)
|
|
472
|
+
await this.store.mutate(card.id, (c) => {
|
|
473
|
+
c.worktreePath = wt
|
|
474
|
+
c.branch = branch
|
|
475
|
+
c.baseRef = base
|
|
476
|
+
c.baseSha = baseSha ?? undefined
|
|
477
|
+
}, wsPath)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const current = await this.store.get(wsPath, card.id)
|
|
482
|
+
if (current === undefined || current.plan === undefined || current.plan.phases.length === 0) {
|
|
483
|
+
await this.fail(card.id, 'phase_failed', '卡片没有实现计划', 'running')
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
const plan = current.plan
|
|
487
|
+
const workdir = current.worktreePath ?? wsPath
|
|
488
|
+
let phaseIndex = current.currentPhase
|
|
489
|
+
|
|
490
|
+
while (phaseIndex < plan.phases.length) {
|
|
491
|
+
if (entry.stopped) {
|
|
492
|
+
await this.store.mutate(card.id, (c) => {
|
|
493
|
+
c.status = 'queued'
|
|
494
|
+
c.queuedAt = Date.now()
|
|
495
|
+
c.stoppedAt = Date.now()
|
|
496
|
+
c.currentPhase = phaseIndex
|
|
497
|
+
c.error = undefined
|
|
498
|
+
}, wsPath)
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
const ok = await this.runPhase(card.id, wsPath, workdir, plan, phaseIndex, entry)
|
|
502
|
+
if (!ok) return
|
|
503
|
+
const freshCard = await this.store.get(wsPath, card.id)
|
|
504
|
+
phaseIndex = freshCard?.currentPhase ?? phaseIndex
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (gitMode) {
|
|
508
|
+
await this.store.mutate(card.id, (c) => { c.status = 'completed' }, wsPath)
|
|
509
|
+
const merged = await this.runMerge(card.id, wsPath)
|
|
510
|
+
if (merged) {
|
|
511
|
+
await this.store.mutate(card.id, (c) => { c.status = 'merged'; c.error = undefined }, wsPath)
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
await this.store.mutate(card.id, (c) => { c.status = 'merged'; c.error = undefined }, wsPath)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private async runPhase(
|
|
519
|
+
cardId: string,
|
|
520
|
+
wsPath: string,
|
|
521
|
+
workdir: string,
|
|
522
|
+
plan: Plan,
|
|
523
|
+
phaseIndex: number,
|
|
524
|
+
entry: RunningEntry,
|
|
525
|
+
): Promise<boolean> {
|
|
526
|
+
const phase = plan.phases[phaseIndex]
|
|
527
|
+
const sessionId = this.newSessionId()
|
|
528
|
+
this.sessionCards.set(sessionId, cardId)
|
|
529
|
+
await this.store.mutate(cardId, (c) => {
|
|
530
|
+
c.status = 'running'
|
|
531
|
+
c.error = undefined
|
|
532
|
+
c.sessions.phases.push({ phaseIndex, sessionId, startedAt: Date.now() })
|
|
533
|
+
}, wsPath)
|
|
534
|
+
const route = await this.modelRoute(cardId, wsPath)
|
|
535
|
+
try {
|
|
536
|
+
const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver())
|
|
537
|
+
this.keepHandle(cardId, handle)
|
|
538
|
+
entry.cancel = () => handle.agent.cancel({ kind: 'user' })
|
|
539
|
+
handle.agent.followup(this.asUserMessage(phasePrompt({ ...(await this.store.get(wsPath, cardId)) ?? { plan } as KanbanCard, plan } as KanbanCard, phaseIndex, workdir)))
|
|
540
|
+
await handle.agent.whenIdle()
|
|
541
|
+
if (entry.stopped) {
|
|
542
|
+
await this.store.mutate(cardId, (c) => {
|
|
543
|
+
c.status = 'queued'
|
|
544
|
+
c.queuedAt = Date.now()
|
|
545
|
+
c.stoppedAt = Date.now()
|
|
546
|
+
c.currentPhase = phaseIndex
|
|
547
|
+
c.error = undefined
|
|
548
|
+
}, wsPath)
|
|
549
|
+
return false
|
|
550
|
+
}
|
|
551
|
+
const done = this.completed.get(sessionId) === true
|
|
552
|
+
this.completed.delete(sessionId)
|
|
553
|
+
if (!done) {
|
|
554
|
+
await this.fail(cardId, 'phase_failed', `phase ${phase.id} 的会话未调用 kanban_phase_complete 就结束`, 'running')
|
|
555
|
+
return false
|
|
556
|
+
}
|
|
557
|
+
await this.store.mutate(cardId, (c) => { c.currentPhase = phaseIndex + 1 }, wsPath)
|
|
558
|
+
return true
|
|
559
|
+
} catch (error) {
|
|
560
|
+
await this.fail(cardId, 'phase_failed', `运行 phase ${phase.id} 失败: ${String(error)}`, 'running')
|
|
561
|
+
return false
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private async resolveConflictsIn(
|
|
566
|
+
cardId: string,
|
|
567
|
+
wsPath: string,
|
|
568
|
+
workdir: string,
|
|
569
|
+
opts: { keepUnstaged?: boolean } = {},
|
|
570
|
+
): Promise<boolean> {
|
|
571
|
+
const conflicts = await unmergedPaths(workdir)
|
|
572
|
+
if (conflicts === '') return true
|
|
573
|
+
const ok = await this.runMergeSession(cardId, wsPath, workdir, conflicts)
|
|
574
|
+
if (!ok) return false
|
|
575
|
+
// The AI resolves in the working tree only; the plugin stages the
|
|
576
|
+
// resolution, which is also what clears the unmerged index entries.
|
|
577
|
+
const add = await runGit(workdir, ['add', '-A'])
|
|
578
|
+
if (add.code !== 0) {
|
|
579
|
+
await this.fail(cardId, 'merge_failed', `git add 失败: ${add.stderr}`, 'completed')
|
|
580
|
+
return false
|
|
581
|
+
}
|
|
582
|
+
const stillUnmerged = await unmergedPaths(workdir)
|
|
583
|
+
if (stillUnmerged !== '') {
|
|
584
|
+
await this.fail(cardId, 'merge_failed', '合并会话后仍有未解决的冲突: ' + stillUnmerged, 'completed')
|
|
585
|
+
return false
|
|
586
|
+
}
|
|
587
|
+
const leftover = await this.verifyConflictMarkers(workdir, conflicts.split('\n'))
|
|
588
|
+
if (leftover.length > 0) {
|
|
589
|
+
await this.fail(cardId, 'merge_failed', '合并会话后仍有冲突标记残留: ' + leftover.join(', '), 'completed')
|
|
590
|
+
return false
|
|
591
|
+
}
|
|
592
|
+
if (opts.keepUnstaged === true) {
|
|
593
|
+
// stash-pop semantics: the user's changes stay uncommitted and unstaged
|
|
594
|
+
await runGit(workdir, ['restore', '--staged', '.'])
|
|
595
|
+
}
|
|
596
|
+
return true
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Check resolved files for leftover conflict markers. */
|
|
600
|
+
private async verifyConflictMarkers(dir: string, files: string[]): Promise<string[]> {
|
|
601
|
+
const leftover: string[] = []
|
|
602
|
+
for (const f of files) {
|
|
603
|
+
if (f.trim() === '') continue
|
|
604
|
+
try {
|
|
605
|
+
const text = await readFile(join(dir, f), 'utf8')
|
|
606
|
+
if (text.split('\n').some((l) => /^(<<<<<<<|=======|>>>>>>>)/.test(l))) leftover.push(f)
|
|
607
|
+
} catch {
|
|
608
|
+
// file missing: nothing to check
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return leftover
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Merge ─────────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
async runMerge(cardId: string, wsPath: string): Promise<boolean> {
|
|
617
|
+
if (this.merging.has(cardId)) return true
|
|
618
|
+
this.merging.add(cardId)
|
|
619
|
+
try {
|
|
620
|
+
const ok = await this.mergeCard(cardId, wsPath)
|
|
621
|
+
if (ok) {
|
|
622
|
+
await this.store.mutate(cardId, (c) => { c.status = 'merged'; c.error = undefined }, wsPath)
|
|
623
|
+
}
|
|
624
|
+
return ok
|
|
625
|
+
} finally {
|
|
626
|
+
this.merging.delete(cardId)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async mergeCard(cardId: string, wsPath: string): Promise<boolean> {
|
|
631
|
+
const card = await this.store.get(wsPath, cardId)
|
|
632
|
+
if (card === undefined || card.worktreePath === undefined || card.branch === undefined || card.baseRef === undefined) {
|
|
633
|
+
await this.fail(cardId, 'merge_failed', '缺少 worktree/分支信息,无法合并', 'completed')
|
|
634
|
+
return false
|
|
635
|
+
}
|
|
636
|
+
const wt = card.worktreePath
|
|
637
|
+
const branch = card.branch
|
|
638
|
+
const base = card.baseRef
|
|
639
|
+
const step = card.merge?.step ?? ('prepare' as const)
|
|
640
|
+
const stashMsg = `kanban-merge-${cardId.slice(0, 8)}`
|
|
641
|
+
await this.store.mutate(cardId, (c) => { c.status = 'merging' }, wsPath)
|
|
642
|
+
|
|
643
|
+
if (step === 'prepare') {
|
|
644
|
+
const cb = await currentBranch(wsPath)
|
|
645
|
+
if (cb !== base) {
|
|
646
|
+
await this.fail(cardId, 'merge_failed', `项目当前分支是 ${cb ?? '?'},不是 ${base},请先切回`, 'completed')
|
|
647
|
+
return false
|
|
648
|
+
}
|
|
649
|
+
let stashed = false
|
|
650
|
+
if (!(await hasStashMessage(wsPath, stashMsg)) && (await isTreeDirty(wsPath))) {
|
|
651
|
+
const r = await runGit(wsPath, ['stash', 'push', '-m', stashMsg])
|
|
652
|
+
if (r.code !== 0) {
|
|
653
|
+
await this.fail(cardId, 'merge_failed', `stash 工作区改动失败: ${r.stderr}`, 'completed')
|
|
654
|
+
return false
|
|
655
|
+
}
|
|
656
|
+
stashed = true
|
|
657
|
+
}
|
|
658
|
+
await this.store.mutate(cardId, (c) => { c.merge = { step: 'wt-merge', stashApplied: stashed } }, wsPath)
|
|
659
|
+
return this.mergeCard(cardId, wsPath)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (step === 'wt-merge') {
|
|
663
|
+
const inProgress = (await unmergedPaths(wt)) !== ''
|
|
664
|
+
if (inProgress) {
|
|
665
|
+
// A previous attempt left the merge half-done (retry path): resolve
|
|
666
|
+
// the pending conflicts before anything else.
|
|
667
|
+
if (!(await this.resolveConflictsIn(cardId, wsPath, wt))) return false
|
|
668
|
+
const add = await runGit(wt, ['add', '-A'])
|
|
669
|
+
if (add.code !== 0) {
|
|
670
|
+
await this.fail(cardId, 'merge_failed', `git add 失败: ${add.stderr}`, 'completed')
|
|
671
|
+
return false
|
|
672
|
+
}
|
|
673
|
+
const commit = await runGit(wt, ['commit', '-m', `kanban: 合并 ${base} 到 ${branch}(任务 ${cardId.slice(0, 8)})`])
|
|
674
|
+
if (commit.code !== 0) {
|
|
675
|
+
await this.fail(cardId, 'merge_failed', `提交冲突解决结果失败: ${commit.stderr}`, 'completed')
|
|
676
|
+
return false
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
// Phase sessions never commit (the plugin owns commits): fold their
|
|
680
|
+
// work into a plugin-authored commit first, or `git merge` refuses on
|
|
681
|
+
// a dirty tree.
|
|
682
|
+
const wtStatus = await runGit(wt, ['status', '--porcelain'])
|
|
683
|
+
if (wtStatus.code === 0 && wtStatus.stdout.trim() !== '') {
|
|
684
|
+
const add = await runGit(wt, ['add', '-A'])
|
|
685
|
+
if (add.code !== 0) {
|
|
686
|
+
await this.fail(cardId, 'merge_failed', `git add 失败: ${add.stderr}`, 'completed')
|
|
687
|
+
return false
|
|
688
|
+
}
|
|
689
|
+
const commit = await runGit(wt, ['commit', '-m', `kanban: phase 实现成果(任务 ${cardId.slice(0, 8)})`])
|
|
690
|
+
if (commit.code !== 0) {
|
|
691
|
+
await this.fail(cardId, 'merge_failed', `提交 phase 成果失败: ${commit.stderr}`, 'completed')
|
|
692
|
+
return false
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const r = await runGit(wt, ['merge', base, '--no-edit'])
|
|
696
|
+
if (r.code !== 0) {
|
|
697
|
+
const conflicts = await unmergedPaths(wt)
|
|
698
|
+
if (conflicts !== '') {
|
|
699
|
+
if (!(await this.resolveConflictsIn(cardId, wsPath, wt))) return false
|
|
700
|
+
const add = await runGit(wt, ['add', '-A'])
|
|
701
|
+
if (add.code !== 0) {
|
|
702
|
+
await this.fail(cardId, 'merge_failed', `git add 失败: ${add.stderr}`, 'completed')
|
|
703
|
+
return false
|
|
704
|
+
}
|
|
705
|
+
const commit = await runGit(wt, ['commit', '-m', `kanban: 合并 ${base} 到 ${branch}(任务 ${cardId.slice(0, 8)})`])
|
|
706
|
+
if (commit.code !== 0) {
|
|
707
|
+
await this.fail(cardId, 'merge_failed', `提交冲突解决结果失败: ${commit.stderr}`, 'completed')
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
await this.fail(cardId, 'merge_failed', `worktree 合入 ${base} 失败: ${r.stderr}`, 'completed')
|
|
712
|
+
return false
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
await this.store.mutate(cardId, (c) => { c.merge = { step: 'ws-merge', stashApplied: c.merge?.stashApplied ?? false } }, wsPath)
|
|
717
|
+
return this.mergeCard(cardId, wsPath)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (step === 'ws-merge') {
|
|
721
|
+
const r = await runGit(wsPath, ['merge', branch, '--no-edit'])
|
|
722
|
+
let mergeCommit = await revParse(wsPath, 'HEAD')
|
|
723
|
+
if (r.code !== 0) {
|
|
724
|
+
const conflicts = await unmergedPaths(wsPath)
|
|
725
|
+
if (conflicts !== '') {
|
|
726
|
+
if (!(await this.resolveConflictsIn(cardId, wsPath, wsPath))) return false
|
|
727
|
+
const add = await runGit(wsPath, ['add', '-A'])
|
|
728
|
+
if (add.code !== 0) {
|
|
729
|
+
await this.fail(cardId, 'merge_failed', `git add 失败: ${add.stderr}`, 'completed')
|
|
730
|
+
return false
|
|
731
|
+
}
|
|
732
|
+
const commit = await runGit(wsPath, ['commit', '--no-edit', '-m', `kanban: 合并 ${branch} 到 ${base}(任务 ${cardId.slice(0, 8)})`])
|
|
733
|
+
if (commit.code !== 0) {
|
|
734
|
+
await this.fail(cardId, 'merge_failed', `提交合并失败: ${commit.stderr}`, 'completed')
|
|
735
|
+
return false
|
|
736
|
+
}
|
|
737
|
+
mergeCommit = await revParse(wsPath, 'HEAD')
|
|
738
|
+
} else {
|
|
739
|
+
await this.fail(cardId, 'merge_failed', `合入主分支失败: ${r.stderr}`, 'completed')
|
|
740
|
+
return false
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
await this.store.mutate(cardId, (c) => { c.merge = { step: 'pop', stashApplied: c.merge?.stashApplied ?? false, mergeCommit } }, wsPath)
|
|
744
|
+
return this.mergeCard(cardId, wsPath)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (step === 'pop') {
|
|
748
|
+
if (await hasStashMessage(wsPath, stashMsg)) {
|
|
749
|
+
const r = await runGit(wsPath, ['stash', 'pop'])
|
|
750
|
+
if (r.code !== 0) {
|
|
751
|
+
const conflicts = await unmergedPaths(wsPath)
|
|
752
|
+
if (conflicts !== '') {
|
|
753
|
+
if (!(await this.resolveConflictsIn(cardId, wsPath, wsPath, { keepUnstaged: true }))) return false
|
|
754
|
+
const check = await runGit(wsPath, ['diff', '--check'])
|
|
755
|
+
if (check.code !== 0) {
|
|
756
|
+
await this.fail(cardId, 'merge_failed', `stash pop 冲突解决后仍有残留问题: ${check.stderr}`, 'completed')
|
|
757
|
+
return false
|
|
758
|
+
}
|
|
759
|
+
await runGit(wsPath, ['stash', 'drop'])
|
|
760
|
+
} else {
|
|
761
|
+
await this.fail(cardId, 'merge_failed', `stash pop 失败: ${r.stderr}`, 'completed')
|
|
762
|
+
return false
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
await this.store.mutate(cardId, (c) => {
|
|
767
|
+
c.merge = { step: 'cleanup', stashApplied: false, mergeCommit: c.merge?.mergeCommit }
|
|
768
|
+
}, wsPath)
|
|
769
|
+
return this.mergeCard(cardId, wsPath)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// cleanup (final step, no resume needed after)
|
|
773
|
+
await runGit(wsPath, ['worktree', 'remove', '--force', wt])
|
|
774
|
+
if (card.branch !== undefined) await runGit(wsPath, ['branch', '-D', card.branch])
|
|
775
|
+
if (await hasStashMessage(wsPath, stashMsg)) await runGit(wsPath, ['stash', 'drop'])
|
|
776
|
+
await this.store.mutate(cardId, (c) => {
|
|
777
|
+
c.worktreePath = undefined
|
|
778
|
+
c.merge = { step: 'cleanup', stashApplied: false, mergeCommit: c.merge?.mergeCommit }
|
|
779
|
+
}, wsPath)
|
|
780
|
+
return true
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private async runMergeSession(cardId: string, wsPath: string, workdir: string, conflicts: string): Promise<boolean> {
|
|
784
|
+
const sessionId = this.newSessionId()
|
|
785
|
+
this.sessionCards.set(sessionId, cardId)
|
|
786
|
+
await this.store.mutate(cardId, (c) => { c.sessions.merge.push(sessionId) }, wsPath)
|
|
787
|
+
const route = await this.modelRoute(cardId, wsPath)
|
|
788
|
+
try {
|
|
789
|
+
const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver())
|
|
790
|
+
this.keepHandle(cardId, handle)
|
|
791
|
+
handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)))
|
|
792
|
+
await handle.agent.whenIdle()
|
|
793
|
+
const done = this.completed.get(sessionId) === true
|
|
794
|
+
this.completed.delete(sessionId)
|
|
795
|
+
if (!done) {
|
|
796
|
+
try {
|
|
797
|
+
const events = handle.agent.session.events
|
|
798
|
+
const tail = events.slice(-6).map((e) => ({
|
|
799
|
+
type: e.type,
|
|
800
|
+
...(e.data !== undefined ? { data: JSON.stringify(e.data).slice(0, 500) } : {}),
|
|
801
|
+
}))
|
|
802
|
+
console.error('[task-kanban] merge session tail:', JSON.stringify(tail, null, 1))
|
|
803
|
+
} catch (error) {
|
|
804
|
+
console.error('[task-kanban] merge session events unreadable:', String(error))
|
|
805
|
+
}
|
|
806
|
+
await this.fail(cardId, 'merge_failed', '合并会话结束但未调用 kanban_merge_resolved 声明解决', 'completed')
|
|
807
|
+
}
|
|
808
|
+
return done
|
|
809
|
+
} catch (error) {
|
|
810
|
+
await this.fail(cardId, 'merge_failed', `合并会话失败: ${String(error)}`, 'completed')
|
|
811
|
+
return false
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── .gitignore ────────────────────────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
private async ensureGitignoreDsh(workspacePath: string): Promise<void> {
|
|
818
|
+
if (!(await isGitRepo(workspacePath))) return
|
|
819
|
+
const ignorePath = join(workspacePath, '.gitignore')
|
|
820
|
+
let content = ''
|
|
821
|
+
try {
|
|
822
|
+
content = await readFile(ignorePath, 'utf8')
|
|
823
|
+
} catch {
|
|
824
|
+
content = ''
|
|
825
|
+
}
|
|
826
|
+
if (content.includes('.dsh')) return
|
|
827
|
+
const extra = (content === '' ? '' : '\n') + '# @fonlan/dsh-task-kanban\n.dsh/\n'
|
|
828
|
+
try {
|
|
829
|
+
await appendFile(ignorePath, extra)
|
|
830
|
+
} catch {
|
|
831
|
+
// workspace not writable: non-fatal
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|