@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.
@@ -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
+ }