@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
10
|
+
//#region src/server/settings.ts
|
|
11
|
+
const KANBAN_SETTINGS_NS = settingsNamespace("task-kanban");
|
|
12
|
+
const KanbanSettingsSchema = z.object({
|
|
13
|
+
maxParallelWorkers: z.natural().min(1).default(1),
|
|
14
|
+
defaultModel: z.string().default("")
|
|
15
|
+
});
|
|
16
|
+
function registerSettings(ctx) {
|
|
17
|
+
let current = {
|
|
18
|
+
maxParallelWorkers: 1,
|
|
19
|
+
defaultModel: ""
|
|
20
|
+
};
|
|
21
|
+
let service;
|
|
22
|
+
ctx.inject(["settings"], (sctx) => {
|
|
23
|
+
service = sctx.settings;
|
|
24
|
+
const scope = service.register(KANBAN_SETTINGS_NS, KanbanSettingsSchema);
|
|
25
|
+
current = scope.get();
|
|
26
|
+
scope.watch((next) => {
|
|
27
|
+
current = next;
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
get: () => current,
|
|
32
|
+
defaultModelRoute: () => {
|
|
33
|
+
if (service === void 0) return {};
|
|
34
|
+
try {
|
|
35
|
+
const descriptors = service.describe({ redactSecrets: true });
|
|
36
|
+
for (const d of descriptors) if (String(d.ns) === "agent-default-model") {
|
|
37
|
+
const v = d.value ?? {};
|
|
38
|
+
return {
|
|
39
|
+
provider: v.provider,
|
|
40
|
+
model: v.model
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
return {};
|
|
45
|
+
},
|
|
46
|
+
update: async (patch) => {
|
|
47
|
+
if (service === void 0) throw new Error("@fonlan/dsh-task-kanban: settings service is not available in this profile");
|
|
48
|
+
await service.update(KANBAN_SETTINGS_NS, patch);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/server/task-store.ts
|
|
54
|
+
const TASKS_REL = [
|
|
55
|
+
".dsh",
|
|
56
|
+
"task-kanban",
|
|
57
|
+
"tasks"
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* File-backed task store: one JSON file per card under
|
|
61
|
+
* `<workspace>/.dsh/task-kanban/tasks/<id>.json`. In-process cache plus
|
|
62
|
+
* atomic (tmp+rename) writes, with a per-card mutex so worker and RPC
|
|
63
|
+
* mutations never interleave.
|
|
64
|
+
*/
|
|
65
|
+
var TaskStore = class {
|
|
66
|
+
cache = /* @__PURE__ */ new Map();
|
|
67
|
+
locks = /* @__PURE__ */ new Map();
|
|
68
|
+
taskDir(workspacePath) {
|
|
69
|
+
return join(workspacePath, ...TASKS_REL);
|
|
70
|
+
}
|
|
71
|
+
fileOf(workspacePath, id) {
|
|
72
|
+
return join(this.taskDir(workspacePath), `${id}.json`);
|
|
73
|
+
}
|
|
74
|
+
withLock(key, fn) {
|
|
75
|
+
const next = (this.locks.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
76
|
+
this.locks.set(key, next.catch(() => void 0));
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
async list(workspacePath) {
|
|
80
|
+
const dir = this.taskDir(workspacePath);
|
|
81
|
+
let files;
|
|
82
|
+
try {
|
|
83
|
+
files = await readdir(dir);
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
if (!f.endsWith(".json")) continue;
|
|
90
|
+
const id = f.slice(0, -5);
|
|
91
|
+
const card = await this.get(workspacePath, id);
|
|
92
|
+
if (card !== void 0) out.push(card);
|
|
93
|
+
}
|
|
94
|
+
out.sort((a, b) => a.createdAt - b.createdAt);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
async get(workspacePath, id) {
|
|
98
|
+
const cached = this.cache.get(id);
|
|
99
|
+
if (cached !== void 0 && cached.workspacePath === workspacePath) return cached;
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(this.fileOf(workspacePath, id), "utf8");
|
|
102
|
+
const card = JSON.parse(raw);
|
|
103
|
+
this.cache.set(id, card);
|
|
104
|
+
return card;
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async create(input) {
|
|
110
|
+
const card = {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
id: randomUUID(),
|
|
113
|
+
workspacePath: input.workspacePath,
|
|
114
|
+
requirement: input.requirement,
|
|
115
|
+
model: input.model,
|
|
116
|
+
...input.provider !== void 0 ? { provider: input.provider } : {},
|
|
117
|
+
status: input.status ?? "draft",
|
|
118
|
+
currentPhase: 0,
|
|
119
|
+
phaseCount: 0,
|
|
120
|
+
sessions: {
|
|
121
|
+
refinement: [],
|
|
122
|
+
phases: [],
|
|
123
|
+
merge: []
|
|
124
|
+
},
|
|
125
|
+
createdAt: Date.now()
|
|
126
|
+
};
|
|
127
|
+
await this.write(card);
|
|
128
|
+
return card;
|
|
129
|
+
}
|
|
130
|
+
/** Run a mutation under the card's lock; returns the updated card. */
|
|
131
|
+
async mutate(id, fn, workspacePath) {
|
|
132
|
+
return this.withLock(id, async () => {
|
|
133
|
+
let card = this.cache.get(id);
|
|
134
|
+
if ((card === void 0 || workspacePath !== void 0 && card.workspacePath !== workspacePath) && workspacePath !== void 0) card = await this.get(workspacePath, id);
|
|
135
|
+
if (card === void 0) return void 0;
|
|
136
|
+
await fn(card);
|
|
137
|
+
await this.write(card);
|
|
138
|
+
return card;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async write(card) {
|
|
142
|
+
const dir = this.taskDir(card.workspacePath);
|
|
143
|
+
await mkdir(dir, { recursive: true });
|
|
144
|
+
const file = this.fileOf(card.workspacePath, card.id);
|
|
145
|
+
const tmp = file + ".tmp";
|
|
146
|
+
await writeFile(tmp, JSON.stringify(card, null, 2), "utf8");
|
|
147
|
+
await rename(tmp, file);
|
|
148
|
+
this.cache.set(card.id, card);
|
|
149
|
+
}
|
|
150
|
+
async remove(workspacePath, id) {
|
|
151
|
+
await this.withLock(id, async () => {
|
|
152
|
+
this.cache.delete(id);
|
|
153
|
+
try {
|
|
154
|
+
await rm(this.fileOf(workspacePath, id), { force: true });
|
|
155
|
+
} catch {}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/server/git.ts
|
|
161
|
+
/** Thin typed wrapper over the git CLI (all merge/web-tree flow runs here). */
|
|
162
|
+
const execFileAsync = promisify(execFile);
|
|
163
|
+
async function runGit(cwd, args) {
|
|
164
|
+
try {
|
|
165
|
+
const { stdout, stderr } = await execFileAsync("git", args, {
|
|
166
|
+
cwd,
|
|
167
|
+
maxBuffer: 134217728,
|
|
168
|
+
timeout: 6e5
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
code: 0,
|
|
172
|
+
stdout: String(stdout),
|
|
173
|
+
stderr: String(stderr)
|
|
174
|
+
};
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const e = error;
|
|
177
|
+
return {
|
|
178
|
+
code: typeof e.code === "number" ? e.code : 1,
|
|
179
|
+
stdout: e.stdout !== void 0 ? String(e.stdout) : "",
|
|
180
|
+
stderr: e.stderr !== void 0 ? String(e.stderr) : String(error)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function isGitRepo(dir) {
|
|
185
|
+
const r = await runGit(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
186
|
+
return r.code === 0 && r.stdout.trim() === "true";
|
|
187
|
+
}
|
|
188
|
+
async function hasBranch(dir, name) {
|
|
189
|
+
return (await runGit(dir, [
|
|
190
|
+
"rev-parse",
|
|
191
|
+
"--verify",
|
|
192
|
+
`refs/heads/${name}`
|
|
193
|
+
])).code === 0;
|
|
194
|
+
}
|
|
195
|
+
async function detectBaseRef(dir) {
|
|
196
|
+
if (await hasBranch(dir, "main")) return "main";
|
|
197
|
+
if (await hasBranch(dir, "master")) return "master";
|
|
198
|
+
}
|
|
199
|
+
async function currentBranch(dir) {
|
|
200
|
+
const r = await runGit(dir, [
|
|
201
|
+
"symbolic-ref",
|
|
202
|
+
"--short",
|
|
203
|
+
"HEAD"
|
|
204
|
+
]);
|
|
205
|
+
return r.code === 0 && r.stdout.trim() !== "" ? r.stdout.trim() : void 0;
|
|
206
|
+
}
|
|
207
|
+
async function revParse(dir, ref) {
|
|
208
|
+
const r = await runGit(dir, ["rev-parse", ref]);
|
|
209
|
+
return r.code === 0 ? r.stdout.trim() : void 0;
|
|
210
|
+
}
|
|
211
|
+
/** True when the working tree has tracked changes (staged or unstaged). */
|
|
212
|
+
async function isTreeDirty(dir) {
|
|
213
|
+
const r = await runGit(dir, [
|
|
214
|
+
"status",
|
|
215
|
+
"--porcelain",
|
|
216
|
+
"--untracked-files=no"
|
|
217
|
+
]);
|
|
218
|
+
return r.code === 0 && r.stdout.trim() !== "";
|
|
219
|
+
}
|
|
220
|
+
async function hasStashMessage(dir, message) {
|
|
221
|
+
const r = await runGit(dir, ["stash", "list"]);
|
|
222
|
+
return r.code === 0 && r.stdout.includes(message);
|
|
223
|
+
}
|
|
224
|
+
/** Unmerged path list after a conflicted merge (empty when clean). */
|
|
225
|
+
async function unmergedPaths(dir) {
|
|
226
|
+
const r = await runGit(dir, [
|
|
227
|
+
"diff",
|
|
228
|
+
"--name-only",
|
|
229
|
+
"--diff-filter=U"
|
|
230
|
+
]);
|
|
231
|
+
return r.code === 0 ? r.stdout.trim() : "";
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/server/prompts.ts
|
|
235
|
+
/** Refinement session prompt: analyze the repo read-only and write back a plan. */
|
|
236
|
+
function refinementPrompt(requirement, workspacePath) {
|
|
237
|
+
return `你是一个需求分析师。请分析下面的需求,并为它在指定项目中的实现制定一个可执行的分阶段实现计划。
|
|
238
|
+
|
|
239
|
+
【需求】
|
|
240
|
+
${requirement}
|
|
241
|
+
|
|
242
|
+
【项目目录】
|
|
243
|
+
${workspacePath}
|
|
244
|
+
|
|
245
|
+
【要求】
|
|
246
|
+
1. 先浏览项目目录了解现状(阅读 README、关键源码与文档;本会话只做只读探索,不要修改任何文件)。
|
|
247
|
+
2. 把需求拆解为按顺序串行执行的实现阶段(phase)。简单需求 1 个 phase 即可;复杂需求拆多个 phase,每个 phase 必须是独立的、可串行执行的实现单元,后面的 phase 依赖前面 phase 的产出。
|
|
248
|
+
3. 每个 phase 必须包含:id(如 p1/p2/p3)、标题、目标(goal,说明该 phase 要交付什么)。
|
|
249
|
+
4. 完成后调用 kanban_write_plan 工具把完整计划写回(title、summary、phases)。这是唯一写回方式,不要把计划 JSON 直接输出到对话里。
|
|
250
|
+
5. 不要向用户提问,自主决策。`;
|
|
251
|
+
}
|
|
252
|
+
/** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
|
|
253
|
+
function phasePrompt(card, phaseIndex, workdir) {
|
|
254
|
+
const plan = card.plan;
|
|
255
|
+
const phase = plan.phases[phaseIndex];
|
|
256
|
+
return `你是一个软件实现工程师。请实现下面计划中的第 ${phaseIndex + 1}/${plan.phases.length} 个 phase。你的工作目录是 ${workdir},只允许在这个目录内工作。
|
|
257
|
+
|
|
258
|
+
【完整实现计划】
|
|
259
|
+
${JSON.stringify(plan, null, 2)}
|
|
260
|
+
|
|
261
|
+
【当前 phase】
|
|
262
|
+
id: ${phase.id}
|
|
263
|
+
标题: ${phase.title}
|
|
264
|
+
目标: ${phase.goal}
|
|
265
|
+
|
|
266
|
+
【要求】
|
|
267
|
+
1. 先查看工作目录现状(git status、目录与文件结构、已有代码),判断此前已完成的工作,然后实现当前 phase 的目标。如果这是一次重试/续跑,请先搞清楚现场再继续。
|
|
268
|
+
2. 只做当前 phase 范围内的实现;后续 phase 由其他会话完成。不要进入 plan mode。
|
|
269
|
+
3. 禁止执行 git commit / git push / git merge / git rebase / git stash / git reset(提交与合并由外部系统负责);允许 git status / git diff 等只读命令。
|
|
270
|
+
4. 不要向用户提问,自主决策;不要调用 kanban_write_plan。
|
|
271
|
+
5. 完成后必须调用 kanban_phase_complete 工具,并在 summary 中简要说明本 phase 完成了什么。只有显式调用该工具才算完成。`;
|
|
272
|
+
}
|
|
273
|
+
/** Merge-conflict resolution session prompt: resolve conflicts only, never commit. */
|
|
274
|
+
function mergePrompt(workdir, conflicts) {
|
|
275
|
+
return `你是一个 git 合并冲突解决专家。目录 ${workdir} 中存在合并冲突,请逐个解决。
|
|
276
|
+
|
|
277
|
+
【冲突文件】
|
|
278
|
+
${conflicts}
|
|
279
|
+
|
|
280
|
+
【要求】
|
|
281
|
+
1. 逐个查看冲突文件(阅读文件内容、git diff),手动解决所有冲突标记(<<<<<<<、=======、>>>>>>>)。
|
|
282
|
+
2. 只修改冲突文件以解决冲突,不要做任何无关改动;不要执行 git commit / git add / git merge / git stash。
|
|
283
|
+
3. 解决完成后调用 kanban_merge_resolved 工具声明完成(可附简短说明)。`;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/shared/plan.ts
|
|
287
|
+
function validatePlan(input) {
|
|
288
|
+
if (typeof input !== "object" || input === null) return {
|
|
289
|
+
ok: false,
|
|
290
|
+
message: "计划必须是对象"
|
|
291
|
+
};
|
|
292
|
+
const obj = input;
|
|
293
|
+
if (typeof obj.title !== "string" || obj.title.trim() === "") return {
|
|
294
|
+
ok: false,
|
|
295
|
+
message: "title 不能为空"
|
|
296
|
+
};
|
|
297
|
+
if (typeof obj.summary !== "string" || obj.summary.trim() === "") return {
|
|
298
|
+
ok: false,
|
|
299
|
+
message: "summary 不能为空"
|
|
300
|
+
};
|
|
301
|
+
if (!Array.isArray(obj.phases) || obj.phases.length === 0) return {
|
|
302
|
+
ok: false,
|
|
303
|
+
message: "phases 必须是非空数组"
|
|
304
|
+
};
|
|
305
|
+
const seen = /* @__PURE__ */ new Set();
|
|
306
|
+
for (const phase of obj.phases) {
|
|
307
|
+
if (typeof phase !== "object" || phase === null) return {
|
|
308
|
+
ok: false,
|
|
309
|
+
message: "phase 必须是对象"
|
|
310
|
+
};
|
|
311
|
+
const p = phase;
|
|
312
|
+
if (typeof p.id !== "string" || typeof p.title !== "string" || typeof p.goal !== "string") return {
|
|
313
|
+
ok: false,
|
|
314
|
+
message: "每个 phase 都必须包含 id/title/goal"
|
|
315
|
+
};
|
|
316
|
+
if (p.id.trim() === "" || p.title.trim() === "" || p.goal.trim() === "") return {
|
|
317
|
+
ok: false,
|
|
318
|
+
message: "每个 phase 的 id/title/goal 都不能为空"
|
|
319
|
+
};
|
|
320
|
+
if (seen.has(p.id)) return {
|
|
321
|
+
ok: false,
|
|
322
|
+
message: `phase id 重复: ${p.id}`
|
|
323
|
+
};
|
|
324
|
+
seen.add(p.id);
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
ok: true,
|
|
328
|
+
plan: {
|
|
329
|
+
schemaVersion: 1,
|
|
330
|
+
title: obj.title,
|
|
331
|
+
summary: obj.summary,
|
|
332
|
+
phases: obj.phases.map((p) => ({
|
|
333
|
+
id: p.id,
|
|
334
|
+
title: p.title,
|
|
335
|
+
goal: p.goal
|
|
336
|
+
}))
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/server/tools.ts
|
|
342
|
+
/**
|
|
343
|
+
* The three kanban model-facing tools, registered SCOPED into the sessions
|
|
344
|
+
* that need them (only refinement/phase/merge agents see them):
|
|
345
|
+
* - kanban_write_plan → refinement session writes the plan back to the card
|
|
346
|
+
* - kanban_phase_complete → a phase implementation session declares its phase done
|
|
347
|
+
* - kanban_merge_resolved → a conflict merge session declares conflicts resolved
|
|
348
|
+
*/
|
|
349
|
+
function registerKanbanTools(agentCtx, resolver) {
|
|
350
|
+
agentCtx.tools.register(defineTool({
|
|
351
|
+
name: "kanban_write_plan",
|
|
352
|
+
description: "把当前需求的完整分阶段实现计划写回任务卡片。调用一次,参数为完整计划(title、summary、按执行顺序排列的 phases)。成功写回后本细化会话即结束。",
|
|
353
|
+
parameters: {
|
|
354
|
+
title: {
|
|
355
|
+
type: "string",
|
|
356
|
+
required: true,
|
|
357
|
+
description: "计划标题"
|
|
358
|
+
},
|
|
359
|
+
summary: {
|
|
360
|
+
type: "string",
|
|
361
|
+
required: true,
|
|
362
|
+
description: "计划摘要"
|
|
363
|
+
},
|
|
364
|
+
phases: {
|
|
365
|
+
type: "array",
|
|
366
|
+
required: true,
|
|
367
|
+
items: {
|
|
368
|
+
type: "object",
|
|
369
|
+
additionalProperties: false,
|
|
370
|
+
properties: {
|
|
371
|
+
id: {
|
|
372
|
+
type: "string",
|
|
373
|
+
required: true,
|
|
374
|
+
description: "阶段 id,如 p1"
|
|
375
|
+
},
|
|
376
|
+
title: {
|
|
377
|
+
type: "string",
|
|
378
|
+
required: true,
|
|
379
|
+
description: "阶段标题"
|
|
380
|
+
},
|
|
381
|
+
goal: {
|
|
382
|
+
type: "string",
|
|
383
|
+
required: true,
|
|
384
|
+
description: "阶段目标:本阶段要交付什么"
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
description: "按顺序串行执行的实现阶段;必须非空"
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
output: {
|
|
392
|
+
schema: {
|
|
393
|
+
type: "object",
|
|
394
|
+
additionalProperties: false,
|
|
395
|
+
properties: {
|
|
396
|
+
ok: {
|
|
397
|
+
type: "boolean",
|
|
398
|
+
required: true
|
|
399
|
+
},
|
|
400
|
+
taskId: {
|
|
401
|
+
type: "string",
|
|
402
|
+
required: true
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
render: (_args, value) => [{
|
|
407
|
+
type: "text",
|
|
408
|
+
text: `实现计划已写回任务卡片 ${value.taskId}。`
|
|
409
|
+
}]
|
|
410
|
+
},
|
|
411
|
+
async execute(args, exec) {
|
|
412
|
+
const sessionId = exec.agent?.id;
|
|
413
|
+
const cardId = resolver.cardOfSession(sessionId);
|
|
414
|
+
if (cardId === void 0) throw new Error("当前会话没有关联的任务卡片,无法写回计划");
|
|
415
|
+
const result = validatePlan(args);
|
|
416
|
+
if (!result.ok) throw new Error(result.message);
|
|
417
|
+
const plan = result.plan;
|
|
418
|
+
await resolver.writePlan(cardId, plan);
|
|
419
|
+
exec.concludeTurn();
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
taskId: cardId
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}));
|
|
426
|
+
agentCtx.tools.register(defineTool({
|
|
427
|
+
name: "kanban_phase_complete",
|
|
428
|
+
description: "声明当前 phase 实现完成。在工作完成后必须显式调用本工具(附完成摘要),否则该 phase 会被视为未完成。",
|
|
429
|
+
parameters: { summary: {
|
|
430
|
+
type: "string",
|
|
431
|
+
required: true,
|
|
432
|
+
description: "本 phase 完成了什么的简要说明"
|
|
433
|
+
} },
|
|
434
|
+
output: {
|
|
435
|
+
schema: {
|
|
436
|
+
type: "object",
|
|
437
|
+
additionalProperties: false,
|
|
438
|
+
properties: {
|
|
439
|
+
ok: {
|
|
440
|
+
type: "boolean",
|
|
441
|
+
required: true
|
|
442
|
+
},
|
|
443
|
+
phase: {
|
|
444
|
+
type: "string",
|
|
445
|
+
required: true
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
render: (_args, value) => [{
|
|
450
|
+
type: "text",
|
|
451
|
+
text: `phase ${value.phase} 已标记完成。`
|
|
452
|
+
}]
|
|
453
|
+
},
|
|
454
|
+
async execute(args, exec) {
|
|
455
|
+
const sessionId = exec.agent?.id;
|
|
456
|
+
if (resolver.cardOfSession(sessionId) === void 0) throw new Error("当前会话没有关联的任务卡片");
|
|
457
|
+
if (typeof args.summary !== "string" || args.summary.trim() === "") throw new Error("summary 不能为空");
|
|
458
|
+
const phase = await resolver.phaseComplete(sessionId, args.summary);
|
|
459
|
+
exec.concludeTurn();
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
phase
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}));
|
|
466
|
+
agentCtx.tools.register(defineTool({
|
|
467
|
+
name: "kanban_merge_resolved",
|
|
468
|
+
description: "声明当前目录中的合并冲突已全部解决。只用于合并冲突解决会话;声明后本会话即将结束。",
|
|
469
|
+
parameters: { summary: {
|
|
470
|
+
type: "string",
|
|
471
|
+
description: "可选的解决方案说明"
|
|
472
|
+
} },
|
|
473
|
+
output: {
|
|
474
|
+
schema: {
|
|
475
|
+
type: "object",
|
|
476
|
+
additionalProperties: false,
|
|
477
|
+
properties: { ok: {
|
|
478
|
+
type: "boolean",
|
|
479
|
+
required: true
|
|
480
|
+
} }
|
|
481
|
+
},
|
|
482
|
+
render: () => [{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: "冲突已标记为解决。"
|
|
485
|
+
}]
|
|
486
|
+
},
|
|
487
|
+
async execute(_args, exec) {
|
|
488
|
+
const sessionId = exec.agent?.id;
|
|
489
|
+
if (resolver.cardOfSession(sessionId) === void 0) throw new Error("当前会话没有关联的任务卡片");
|
|
490
|
+
await resolver.mergeResolved(sessionId);
|
|
491
|
+
exec.concludeTurn();
|
|
492
|
+
return { ok: true };
|
|
493
|
+
}
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/server/runner.ts
|
|
498
|
+
/**
|
|
499
|
+
* KanbanRunner: the host-side engine.
|
|
500
|
+
* - WorkerPool: per-workspace single worker, global parallel cap from settings.
|
|
501
|
+
* - RefinementRunner: creates the refinement session (cwd = project dir).
|
|
502
|
+
* - PhaseRunner: git worktree bootstrap + one session per phase, same worktree.
|
|
503
|
+
* - MergeRunner: pure-CLI auto-merge with an AI merge session on conflicts.
|
|
504
|
+
*/
|
|
505
|
+
const WORKTREES_REL = [".dsh", "worktrees"];
|
|
506
|
+
var KanbanRunner = class {
|
|
507
|
+
ctx;
|
|
508
|
+
store;
|
|
509
|
+
settings;
|
|
510
|
+
runningByWs = /* @__PURE__ */ new Map();
|
|
511
|
+
slots = 0;
|
|
512
|
+
pumpTimer;
|
|
513
|
+
/** sessionId → cardId (sessions created for this kanban). */
|
|
514
|
+
sessionCards = /* @__PURE__ */ new Map();
|
|
515
|
+
/** sessionId → true once the scoped completion tool ran. */
|
|
516
|
+
completed = /* @__PURE__ */ new Map();
|
|
517
|
+
merging = /* @__PURE__ */ new Set();
|
|
518
|
+
/** Retained agent handles (kept so ended sessions stay in the store/UI). */
|
|
519
|
+
handles = /* @__PURE__ */ new Map();
|
|
520
|
+
constructor(ctx, store, settings) {
|
|
521
|
+
this.ctx = ctx;
|
|
522
|
+
this.store = store;
|
|
523
|
+
this.settings = settings;
|
|
524
|
+
}
|
|
525
|
+
register(ctx) {
|
|
526
|
+
this.ctx = ctx;
|
|
527
|
+
}
|
|
528
|
+
start() {
|
|
529
|
+
this.pumpTimer = setInterval(() => {
|
|
530
|
+
this.pump();
|
|
531
|
+
}, 2500);
|
|
532
|
+
}
|
|
533
|
+
stop() {
|
|
534
|
+
if (this.pumpTimer !== void 0) clearInterval(this.pumpTimer);
|
|
535
|
+
}
|
|
536
|
+
newSessionId() {
|
|
537
|
+
return `session-${randomUUID()}`;
|
|
538
|
+
}
|
|
539
|
+
asUserMessage(text) {
|
|
540
|
+
return createUserMessage({
|
|
541
|
+
content: [{
|
|
542
|
+
type: "text",
|
|
543
|
+
text
|
|
544
|
+
}],
|
|
545
|
+
source: { kind: "user" }
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async workspaceList() {
|
|
549
|
+
const ws = this.ctx.get("workspace") ?? this.ctx.get("workspaceRegistry");
|
|
550
|
+
if (ws === void 0) return [];
|
|
551
|
+
try {
|
|
552
|
+
return ws.list();
|
|
553
|
+
} catch {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
cardOfSession(sessionId) {
|
|
558
|
+
return sessionId === void 0 ? void 0 : this.sessionCards.get(sessionId);
|
|
559
|
+
}
|
|
560
|
+
async writePlan(cardId, plan) {
|
|
561
|
+
await this.store.mutate(cardId, (card) => {
|
|
562
|
+
card.plan = plan;
|
|
563
|
+
card.phaseCount = plan.phases.length;
|
|
564
|
+
card.status = "planned";
|
|
565
|
+
card.error = void 0;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
async phaseComplete(sessionId, summary) {
|
|
569
|
+
const cardId = this.sessionCards.get(sessionId);
|
|
570
|
+
if (cardId === void 0) return "";
|
|
571
|
+
this.completed.set(sessionId, true);
|
|
572
|
+
let phaseId = "";
|
|
573
|
+
await this.store.mutate(cardId, (card) => {
|
|
574
|
+
const attempt = card.sessions.phases.find((a) => a.sessionId === sessionId);
|
|
575
|
+
if (attempt !== void 0) {
|
|
576
|
+
attempt.summary = summary;
|
|
577
|
+
attempt.completedAt = Date.now();
|
|
578
|
+
if (card.plan !== void 0 && card.plan.phases[attempt.phaseIndex] !== void 0) phaseId = card.plan.phases[attempt.phaseIndex].id;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
return phaseId;
|
|
582
|
+
}
|
|
583
|
+
async mergeResolved(sessionId) {
|
|
584
|
+
if (this.sessionCards.get(sessionId) === void 0) return;
|
|
585
|
+
this.completed.set(sessionId, true);
|
|
586
|
+
}
|
|
587
|
+
toolResolver() {
|
|
588
|
+
return {
|
|
589
|
+
cardOfSession: (sid) => this.cardOfSession(sid),
|
|
590
|
+
writePlan: (cardId, plan) => this.writePlan(cardId, plan),
|
|
591
|
+
phaseComplete: (sid, summary) => this.phaseComplete(sid, summary),
|
|
592
|
+
mergeResolved: (sid) => this.mergeResolved(sid)
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async recoverInterrupted() {
|
|
596
|
+
for (const ws of await this.workspaceList()) {
|
|
597
|
+
const cards = await this.store.list(ws.path);
|
|
598
|
+
for (const c of cards) if (c.status === "running" || c.status === "refining" || c.status === "merging") {
|
|
599
|
+
const stage = c.status === "running" ? "running" : c.status === "merging" ? "completed" : "demand";
|
|
600
|
+
await this.store.mutate(c.id, (card) => {
|
|
601
|
+
card.status = "error";
|
|
602
|
+
card.error = {
|
|
603
|
+
kind: "interrupted",
|
|
604
|
+
stage,
|
|
605
|
+
message: "服务重启导致会话中断,请点击重试继续",
|
|
606
|
+
at: Date.now()
|
|
607
|
+
};
|
|
608
|
+
}, ws.path);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async listCards(workspacePath) {
|
|
613
|
+
return this.store.list(workspacePath);
|
|
614
|
+
}
|
|
615
|
+
async createTask(input) {
|
|
616
|
+
const card = await this.store.create({
|
|
617
|
+
workspacePath: input.workspacePath,
|
|
618
|
+
requirement: input.requirement,
|
|
619
|
+
model: input.model,
|
|
620
|
+
provider: input.provider,
|
|
621
|
+
status: "refining"
|
|
622
|
+
});
|
|
623
|
+
await this.ensureGitignoreDsh(input.workspacePath);
|
|
624
|
+
this.runRefinement(card.id, input.workspacePath);
|
|
625
|
+
return card;
|
|
626
|
+
}
|
|
627
|
+
async moveTask(cardId, toLane) {
|
|
628
|
+
const card = await this.findCard(cardId);
|
|
629
|
+
if (card === void 0) return {
|
|
630
|
+
ok: false,
|
|
631
|
+
message: "卡片不存在"
|
|
632
|
+
};
|
|
633
|
+
if (toLane === "queue") {
|
|
634
|
+
if (card.status === "running" || card.status === "refining" || card.status === "merging") {
|
|
635
|
+
if (card.status === "running") {
|
|
636
|
+
await this.requestStop(cardId);
|
|
637
|
+
return { ok: true };
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
message: "细化/合并中的卡片不能拖回队列"
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (card.status !== "planned") return {
|
|
645
|
+
ok: false,
|
|
646
|
+
message: "只有已规划(有实现计划)的卡片才能进入队列"
|
|
647
|
+
};
|
|
648
|
+
await this.store.mutate(cardId, (c) => {
|
|
649
|
+
c.status = "queued";
|
|
650
|
+
c.queuedAt = Date.now();
|
|
651
|
+
c.error = void 0;
|
|
652
|
+
}, card.workspacePath);
|
|
653
|
+
this.pump();
|
|
654
|
+
return { ok: true };
|
|
655
|
+
}
|
|
656
|
+
if (toLane === "demand") {
|
|
657
|
+
if (card.status !== "queued") return {
|
|
658
|
+
ok: false,
|
|
659
|
+
message: "只有队列中的卡片可以拖回需求区"
|
|
660
|
+
};
|
|
661
|
+
await this.store.mutate(cardId, (c) => {
|
|
662
|
+
c.status = "planned";
|
|
663
|
+
c.queuedAt = void 0;
|
|
664
|
+
c.error = void 0;
|
|
665
|
+
}, card.workspacePath);
|
|
666
|
+
return { ok: true };
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
ok: false,
|
|
670
|
+
message: "目标泳道不接受拖拽"
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
async stopTask(cardId) {
|
|
674
|
+
const card = await this.findCard(cardId);
|
|
675
|
+
if (card === void 0) return {
|
|
676
|
+
ok: false,
|
|
677
|
+
message: "卡片不存在"
|
|
678
|
+
};
|
|
679
|
+
if (card.status !== "running" && card.status !== "merging") return {
|
|
680
|
+
ok: false,
|
|
681
|
+
message: "只有运行中的卡片可以停止"
|
|
682
|
+
};
|
|
683
|
+
if (card.status === "merging") return {
|
|
684
|
+
ok: false,
|
|
685
|
+
message: "合并流程中不能停止"
|
|
686
|
+
};
|
|
687
|
+
await this.requestStop(cardId);
|
|
688
|
+
return { ok: true };
|
|
689
|
+
}
|
|
690
|
+
async retryTask(cardId) {
|
|
691
|
+
const card = await this.findCard(cardId);
|
|
692
|
+
if (card === void 0) return {
|
|
693
|
+
ok: false,
|
|
694
|
+
message: "卡片不存在"
|
|
695
|
+
};
|
|
696
|
+
if (card.status !== "error") return {
|
|
697
|
+
ok: false,
|
|
698
|
+
message: "只有错误状态的卡片可以重试"
|
|
699
|
+
};
|
|
700
|
+
const kind = card.error?.kind;
|
|
701
|
+
if (kind === "refine_failed" || kind === "interrupted" && card.error?.stage === "demand") {
|
|
702
|
+
await this.store.mutate(cardId, (c) => {
|
|
703
|
+
c.status = "refining";
|
|
704
|
+
c.error = void 0;
|
|
705
|
+
}, card.workspacePath);
|
|
706
|
+
this.runRefinement(cardId, card.workspacePath);
|
|
707
|
+
return { ok: true };
|
|
708
|
+
}
|
|
709
|
+
if (kind === "merge_failed" || kind === "interrupted" && card.error?.stage === "completed") {
|
|
710
|
+
await this.store.mutate(cardId, (c) => {
|
|
711
|
+
c.status = "completed";
|
|
712
|
+
c.error = void 0;
|
|
713
|
+
}, card.workspacePath);
|
|
714
|
+
this.runMerge(cardId, card.workspacePath);
|
|
715
|
+
return { ok: true };
|
|
716
|
+
}
|
|
717
|
+
await this.store.mutate(cardId, (c) => {
|
|
718
|
+
c.status = "queued";
|
|
719
|
+
c.queuedAt = Date.now();
|
|
720
|
+
c.error = void 0;
|
|
721
|
+
}, card.workspacePath);
|
|
722
|
+
this.pump();
|
|
723
|
+
return { ok: true };
|
|
724
|
+
}
|
|
725
|
+
async deleteTask(cardId) {
|
|
726
|
+
const card = await this.findCard(cardId);
|
|
727
|
+
if (card === void 0) return {
|
|
728
|
+
ok: false,
|
|
729
|
+
message: "卡片不存在"
|
|
730
|
+
};
|
|
731
|
+
if (card.status === "running" || card.status === "refining" || card.status === "merging") return {
|
|
732
|
+
ok: false,
|
|
733
|
+
message: "运行中的卡片必须先停止才能删除"
|
|
734
|
+
};
|
|
735
|
+
if (card.gitMode && card.worktreePath !== void 0) {
|
|
736
|
+
await runGit(card.workspacePath, [
|
|
737
|
+
"worktree",
|
|
738
|
+
"remove",
|
|
739
|
+
"--force",
|
|
740
|
+
card.worktreePath
|
|
741
|
+
]);
|
|
742
|
+
if (card.branch !== void 0) await runGit(card.workspacePath, [
|
|
743
|
+
"branch",
|
|
744
|
+
"-D",
|
|
745
|
+
card.branch
|
|
746
|
+
]);
|
|
747
|
+
}
|
|
748
|
+
for (const sid of card.sessions.refinement) this.sessionCards.delete(sid);
|
|
749
|
+
for (const a of card.sessions.phases) this.sessionCards.delete(a.sessionId);
|
|
750
|
+
for (const sid of card.sessions.merge) this.sessionCards.delete(sid);
|
|
751
|
+
await this.store.remove(card.workspacePath, cardId);
|
|
752
|
+
return { ok: true };
|
|
753
|
+
}
|
|
754
|
+
async findCard(cardId) {
|
|
755
|
+
for (const ws of await this.workspaceList()) {
|
|
756
|
+
const card = await this.store.get(ws.path, cardId);
|
|
757
|
+
if (card !== void 0) return card;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
maxParallel() {
|
|
761
|
+
return this.settings.get().maxParallelWorkers;
|
|
762
|
+
}
|
|
763
|
+
async pump() {
|
|
764
|
+
if (this.slots >= this.maxParallel()) return;
|
|
765
|
+
const workspaces = await this.workspaceList();
|
|
766
|
+
for (const ws of workspaces) {
|
|
767
|
+
if (this.slots >= this.maxParallel()) return;
|
|
768
|
+
if (this.runningByWs.has(ws.path)) continue;
|
|
769
|
+
const next = (await this.store.list(ws.path)).filter((c) => c.status === "queued").sort((a, b) => (a.queuedAt ?? a.createdAt) - (b.queuedAt ?? b.createdAt))[0];
|
|
770
|
+
if (next === void 0) continue;
|
|
771
|
+
this.slots += 1;
|
|
772
|
+
const entry = {
|
|
773
|
+
cardId: next.id,
|
|
774
|
+
workspacePath: ws.path,
|
|
775
|
+
stopped: false,
|
|
776
|
+
cancel: null
|
|
777
|
+
};
|
|
778
|
+
this.runningByWs.set(ws.path, entry);
|
|
779
|
+
this.runCard(ws.path, next, entry).finally(() => {
|
|
780
|
+
this.slots -= 1;
|
|
781
|
+
this.runningByWs.delete(ws.path);
|
|
782
|
+
this.pump();
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async requestStop(cardId) {
|
|
787
|
+
for (const entry of this.runningByWs.values()) if (entry.cardId === cardId) {
|
|
788
|
+
entry.stopped = true;
|
|
789
|
+
entry.cancel?.();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
async fail(cardId, kind, message, stage) {
|
|
794
|
+
await this.store.mutate(cardId, (card) => {
|
|
795
|
+
card.status = "error";
|
|
796
|
+
card.error = {
|
|
797
|
+
kind,
|
|
798
|
+
message,
|
|
799
|
+
at: Date.now(),
|
|
800
|
+
...stage !== void 0 ? { stage } : {}
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
async runRefinement(cardId, wsPath) {
|
|
805
|
+
const card = await this.store.get(wsPath, cardId);
|
|
806
|
+
if (card === void 0) return;
|
|
807
|
+
const route = await this.modelRoute(cardId, wsPath);
|
|
808
|
+
const sessionId = this.newSessionId();
|
|
809
|
+
this.sessionCards.set(sessionId, cardId);
|
|
810
|
+
await this.store.mutate(cardId, (c) => {
|
|
811
|
+
c.status = "refining";
|
|
812
|
+
c.error = void 0;
|
|
813
|
+
c.sessions.refinement.push(sessionId);
|
|
814
|
+
}, wsPath);
|
|
815
|
+
try {
|
|
816
|
+
const handle = await this.createAgent(sessionId, wsPath, route, this.toolResolver());
|
|
817
|
+
this.keepHandle(cardId, handle);
|
|
818
|
+
await this.attachRefinementSession(wsPath, sessionId);
|
|
819
|
+
handle.agent.followup(this.asUserMessage(refinementPrompt(card.requirement, wsPath)));
|
|
820
|
+
await handle.agent.whenIdle();
|
|
821
|
+
const fresh = await this.store.get(wsPath, cardId);
|
|
822
|
+
if (fresh !== void 0 && fresh.status === "refining") {
|
|
823
|
+
try {
|
|
824
|
+
const tail = handle.agent.session.events.slice(-10).map((e) => ({
|
|
825
|
+
type: e.type,
|
|
826
|
+
...e.data !== void 0 ? { data: JSON.stringify(e.data).slice(0, 600) } : {}
|
|
827
|
+
}));
|
|
828
|
+
console.error("[task-kanban] refinement session tail:", JSON.stringify(tail, null, 1));
|
|
829
|
+
} catch (error) {
|
|
830
|
+
console.error("[task-kanban] could not read session events:", String(error));
|
|
831
|
+
}
|
|
832
|
+
await this.fail(cardId, "refine_failed", "细化会话结束但未通过 kanban_write_plan 写回计划", "demand");
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
console.error("[task-kanban] refinement create/drive failed:", String(error));
|
|
836
|
+
await this.fail(cardId, "refine_failed", `细化会话失败: ${String(error)}`, "demand");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async createAgent(sessionId, cwd, route, resolver) {
|
|
840
|
+
const agentOptions = route.provider !== void 0 && route.model !== void 0 && route.model !== "" ? {
|
|
841
|
+
provider: route.provider,
|
|
842
|
+
model: route.model
|
|
843
|
+
} : void 0;
|
|
844
|
+
return this.ctx.agents.create({
|
|
845
|
+
sessionId,
|
|
846
|
+
meta: { cwd },
|
|
847
|
+
...agentOptions !== void 0 ? { agentOptions } : {},
|
|
848
|
+
setup: async (agentCtx) => {
|
|
849
|
+
const agentPresets = agentCtx.get("agentPresets");
|
|
850
|
+
if (agentPresets !== void 0) await agentPresets.mount(agentCtx, "standard");
|
|
851
|
+
else throw new Error("@fonlan/dsh-task-kanban: agent-presets service is not mounted");
|
|
852
|
+
registerKanbanTools(agentCtx, resolver);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
keepHandle(cardId, handle) {
|
|
857
|
+
const list = this.handles.get(cardId) ?? [];
|
|
858
|
+
list.push(handle);
|
|
859
|
+
this.handles.set(cardId, list);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Account the refinement session to its host workspace so it shows in that
|
|
863
|
+
* workspace's session list. The session's cwd IS the workspace root, so the
|
|
864
|
+
* registry's strict `cwd === workspace.path` check passes. Best-effort only:
|
|
865
|
+
* an unregistered workspace (or a failing registry) must not break the card.
|
|
866
|
+
*/
|
|
867
|
+
async attachRefinementSession(workspacePath, sessionId) {
|
|
868
|
+
const registry = this.attachRegistry();
|
|
869
|
+
if (registry === void 0) return;
|
|
870
|
+
try {
|
|
871
|
+
const workspace = await registry.resolveByPath(workspacePath);
|
|
872
|
+
if (workspace !== void 0) await workspace.attachSession(sessionId);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
console.error("[task-kanban] attach refinement session to workspace failed:", String(error));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/** The host workspace registry, whichever service name carries it. */
|
|
878
|
+
attachRegistry() {
|
|
879
|
+
for (const name of ["workspaceRegistry", "workspace"]) {
|
|
880
|
+
const candidate = this.ctx.get(name);
|
|
881
|
+
if (candidate !== void 0 && typeof candidate.resolveByPath === "function") return candidate;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** Resolve the provider+model route for an agent (both are required). */
|
|
885
|
+
async modelRoute(cardId, wsPath) {
|
|
886
|
+
const card = await this.store.get(wsPath, cardId);
|
|
887
|
+
if (card === void 0) return {};
|
|
888
|
+
if (card.model !== "" && card.provider !== void 0 && card.provider !== "") return {
|
|
889
|
+
provider: card.provider,
|
|
890
|
+
model: card.model
|
|
891
|
+
};
|
|
892
|
+
if (card.model !== "") {
|
|
893
|
+
const fallback = this.settings.defaultModelRoute();
|
|
894
|
+
return fallback.provider !== void 0 ? {
|
|
895
|
+
provider: fallback.provider,
|
|
896
|
+
model: card.model
|
|
897
|
+
} : { model: card.model };
|
|
898
|
+
}
|
|
899
|
+
const userDefault = this.settings.get().defaultModel;
|
|
900
|
+
if (userDefault !== "") {
|
|
901
|
+
const fallback = this.settings.defaultModelRoute();
|
|
902
|
+
return fallback.provider !== void 0 ? {
|
|
903
|
+
provider: fallback.provider,
|
|
904
|
+
model: userDefault
|
|
905
|
+
} : { model: userDefault };
|
|
906
|
+
}
|
|
907
|
+
return this.settings.defaultModelRoute();
|
|
908
|
+
}
|
|
909
|
+
async runCard(wsPath, card, entry) {
|
|
910
|
+
await this.store.mutate(card.id, (c) => {
|
|
911
|
+
if (c.status === "queued") {
|
|
912
|
+
c.status = "running";
|
|
913
|
+
c.error = void 0;
|
|
914
|
+
}
|
|
915
|
+
}, wsPath);
|
|
916
|
+
const gitMode = await isGitRepo(wsPath);
|
|
917
|
+
await this.store.mutate(card.id, (c) => {
|
|
918
|
+
c.gitMode = gitMode;
|
|
919
|
+
}, wsPath);
|
|
920
|
+
if (gitMode) {
|
|
921
|
+
const fresh = await this.store.get(wsPath, card.id);
|
|
922
|
+
if (fresh === void 0) return;
|
|
923
|
+
if (fresh.worktreePath === void 0 || fresh.branch === void 0 || fresh.baseRef === void 0) {
|
|
924
|
+
const base = await detectBaseRef(wsPath);
|
|
925
|
+
if (base === void 0) {
|
|
926
|
+
await this.fail(card.id, "no_base_branch", "项目没有 main 或 master 分支,无法创建 worktree", "running");
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const branch = `kanban/${card.id.slice(0, 8)}`;
|
|
930
|
+
const wt = join(wsPath, ...WORKTREES_REL, card.id);
|
|
931
|
+
await this.store.mutate(card.id, (c) => {
|
|
932
|
+
c.gitMode = true;
|
|
933
|
+
}, wsPath);
|
|
934
|
+
const result = await runGit(wsPath, [
|
|
935
|
+
"worktree",
|
|
936
|
+
"add",
|
|
937
|
+
"-b",
|
|
938
|
+
branch,
|
|
939
|
+
wt,
|
|
940
|
+
base
|
|
941
|
+
]);
|
|
942
|
+
if (result.code !== 0) {
|
|
943
|
+
await this.fail(card.id, "worktree_failed", `创建 worktree 失败: ${result.stderr}`, "running");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const baseSha = await revParse(wsPath, base);
|
|
947
|
+
await this.store.mutate(card.id, (c) => {
|
|
948
|
+
c.worktreePath = wt;
|
|
949
|
+
c.branch = branch;
|
|
950
|
+
c.baseRef = base;
|
|
951
|
+
c.baseSha = baseSha ?? void 0;
|
|
952
|
+
}, wsPath);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const current = await this.store.get(wsPath, card.id);
|
|
956
|
+
if (current === void 0 || current.plan === void 0 || current.plan.phases.length === 0) {
|
|
957
|
+
await this.fail(card.id, "phase_failed", "卡片没有实现计划", "running");
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const plan = current.plan;
|
|
961
|
+
const workdir = current.worktreePath ?? wsPath;
|
|
962
|
+
let phaseIndex = current.currentPhase;
|
|
963
|
+
while (phaseIndex < plan.phases.length) {
|
|
964
|
+
if (entry.stopped) {
|
|
965
|
+
await this.store.mutate(card.id, (c) => {
|
|
966
|
+
c.status = "queued";
|
|
967
|
+
c.queuedAt = Date.now();
|
|
968
|
+
c.stoppedAt = Date.now();
|
|
969
|
+
c.currentPhase = phaseIndex;
|
|
970
|
+
c.error = void 0;
|
|
971
|
+
}, wsPath);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (!await this.runPhase(card.id, wsPath, workdir, plan, phaseIndex, entry)) return;
|
|
975
|
+
phaseIndex = (await this.store.get(wsPath, card.id))?.currentPhase ?? phaseIndex;
|
|
976
|
+
}
|
|
977
|
+
if (gitMode) {
|
|
978
|
+
await this.store.mutate(card.id, (c) => {
|
|
979
|
+
c.status = "completed";
|
|
980
|
+
}, wsPath);
|
|
981
|
+
if (await this.runMerge(card.id, wsPath)) await this.store.mutate(card.id, (c) => {
|
|
982
|
+
c.status = "merged";
|
|
983
|
+
c.error = void 0;
|
|
984
|
+
}, wsPath);
|
|
985
|
+
} else await this.store.mutate(card.id, (c) => {
|
|
986
|
+
c.status = "merged";
|
|
987
|
+
c.error = void 0;
|
|
988
|
+
}, wsPath);
|
|
989
|
+
}
|
|
990
|
+
async runPhase(cardId, wsPath, workdir, plan, phaseIndex, entry) {
|
|
991
|
+
const phase = plan.phases[phaseIndex];
|
|
992
|
+
const sessionId = this.newSessionId();
|
|
993
|
+
this.sessionCards.set(sessionId, cardId);
|
|
994
|
+
await this.store.mutate(cardId, (c) => {
|
|
995
|
+
c.status = "running";
|
|
996
|
+
c.error = void 0;
|
|
997
|
+
c.sessions.phases.push({
|
|
998
|
+
phaseIndex,
|
|
999
|
+
sessionId,
|
|
1000
|
+
startedAt: Date.now()
|
|
1001
|
+
});
|
|
1002
|
+
}, wsPath);
|
|
1003
|
+
const route = await this.modelRoute(cardId, wsPath);
|
|
1004
|
+
try {
|
|
1005
|
+
const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver());
|
|
1006
|
+
this.keepHandle(cardId, handle);
|
|
1007
|
+
entry.cancel = () => handle.agent.cancel({ kind: "user" });
|
|
1008
|
+
handle.agent.followup(this.asUserMessage(phasePrompt({
|
|
1009
|
+
...await this.store.get(wsPath, cardId) ?? { plan },
|
|
1010
|
+
plan
|
|
1011
|
+
}, phaseIndex, workdir)));
|
|
1012
|
+
await handle.agent.whenIdle();
|
|
1013
|
+
if (entry.stopped) {
|
|
1014
|
+
await this.store.mutate(cardId, (c) => {
|
|
1015
|
+
c.status = "queued";
|
|
1016
|
+
c.queuedAt = Date.now();
|
|
1017
|
+
c.stoppedAt = Date.now();
|
|
1018
|
+
c.currentPhase = phaseIndex;
|
|
1019
|
+
c.error = void 0;
|
|
1020
|
+
}, wsPath);
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
const done = this.completed.get(sessionId) === true;
|
|
1024
|
+
this.completed.delete(sessionId);
|
|
1025
|
+
if (!done) {
|
|
1026
|
+
await this.fail(cardId, "phase_failed", `phase ${phase.id} 的会话未调用 kanban_phase_complete 就结束`, "running");
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
await this.store.mutate(cardId, (c) => {
|
|
1030
|
+
c.currentPhase = phaseIndex + 1;
|
|
1031
|
+
}, wsPath);
|
|
1032
|
+
return true;
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
await this.fail(cardId, "phase_failed", `运行 phase ${phase.id} 失败: ${String(error)}`, "running");
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async resolveConflictsIn(cardId, wsPath, workdir, opts = {}) {
|
|
1039
|
+
const conflicts = await unmergedPaths(workdir);
|
|
1040
|
+
if (conflicts === "") return true;
|
|
1041
|
+
if (!await this.runMergeSession(cardId, wsPath, workdir, conflicts)) return false;
|
|
1042
|
+
const add = await runGit(workdir, ["add", "-A"]);
|
|
1043
|
+
if (add.code !== 0) {
|
|
1044
|
+
await this.fail(cardId, "merge_failed", `git add 失败: ${add.stderr}`, "completed");
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
const stillUnmerged = await unmergedPaths(workdir);
|
|
1048
|
+
if (stillUnmerged !== "") {
|
|
1049
|
+
await this.fail(cardId, "merge_failed", "合并会话后仍有未解决的冲突: " + stillUnmerged, "completed");
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
const leftover = await this.verifyConflictMarkers(workdir, conflicts.split("\n"));
|
|
1053
|
+
if (leftover.length > 0) {
|
|
1054
|
+
await this.fail(cardId, "merge_failed", "合并会话后仍有冲突标记残留: " + leftover.join(", "), "completed");
|
|
1055
|
+
return false;
|
|
1056
|
+
}
|
|
1057
|
+
if (opts.keepUnstaged === true) await runGit(workdir, [
|
|
1058
|
+
"restore",
|
|
1059
|
+
"--staged",
|
|
1060
|
+
"."
|
|
1061
|
+
]);
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
/** Check resolved files for leftover conflict markers. */
|
|
1065
|
+
async verifyConflictMarkers(dir, files) {
|
|
1066
|
+
const leftover = [];
|
|
1067
|
+
for (const f of files) {
|
|
1068
|
+
if (f.trim() === "") continue;
|
|
1069
|
+
try {
|
|
1070
|
+
if ((await readFile(join(dir, f), "utf8")).split("\n").some((l) => /^(<<<<<<<|=======|>>>>>>>)/.test(l))) leftover.push(f);
|
|
1071
|
+
} catch {}
|
|
1072
|
+
}
|
|
1073
|
+
return leftover;
|
|
1074
|
+
}
|
|
1075
|
+
async runMerge(cardId, wsPath) {
|
|
1076
|
+
if (this.merging.has(cardId)) return true;
|
|
1077
|
+
this.merging.add(cardId);
|
|
1078
|
+
try {
|
|
1079
|
+
const ok = await this.mergeCard(cardId, wsPath);
|
|
1080
|
+
if (ok) await this.store.mutate(cardId, (c) => {
|
|
1081
|
+
c.status = "merged";
|
|
1082
|
+
c.error = void 0;
|
|
1083
|
+
}, wsPath);
|
|
1084
|
+
return ok;
|
|
1085
|
+
} finally {
|
|
1086
|
+
this.merging.delete(cardId);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
async mergeCard(cardId, wsPath) {
|
|
1090
|
+
const card = await this.store.get(wsPath, cardId);
|
|
1091
|
+
if (card === void 0 || card.worktreePath === void 0 || card.branch === void 0 || card.baseRef === void 0) {
|
|
1092
|
+
await this.fail(cardId, "merge_failed", "缺少 worktree/分支信息,无法合并", "completed");
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
const wt = card.worktreePath;
|
|
1096
|
+
const branch = card.branch;
|
|
1097
|
+
const base = card.baseRef;
|
|
1098
|
+
const step = card.merge?.step ?? "prepare";
|
|
1099
|
+
const stashMsg = `kanban-merge-${cardId.slice(0, 8)}`;
|
|
1100
|
+
await this.store.mutate(cardId, (c) => {
|
|
1101
|
+
c.status = "merging";
|
|
1102
|
+
}, wsPath);
|
|
1103
|
+
if (step === "prepare") {
|
|
1104
|
+
const cb = await currentBranch(wsPath);
|
|
1105
|
+
if (cb !== base) {
|
|
1106
|
+
await this.fail(cardId, "merge_failed", `项目当前分支是 ${cb ?? "?"},不是 ${base},请先切回`, "completed");
|
|
1107
|
+
return false;
|
|
1108
|
+
}
|
|
1109
|
+
let stashed = false;
|
|
1110
|
+
if (!await hasStashMessage(wsPath, stashMsg) && await isTreeDirty(wsPath)) {
|
|
1111
|
+
const r = await runGit(wsPath, [
|
|
1112
|
+
"stash",
|
|
1113
|
+
"push",
|
|
1114
|
+
"-m",
|
|
1115
|
+
stashMsg
|
|
1116
|
+
]);
|
|
1117
|
+
if (r.code !== 0) {
|
|
1118
|
+
await this.fail(cardId, "merge_failed", `stash 工作区改动失败: ${r.stderr}`, "completed");
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
stashed = true;
|
|
1122
|
+
}
|
|
1123
|
+
await this.store.mutate(cardId, (c) => {
|
|
1124
|
+
c.merge = {
|
|
1125
|
+
step: "wt-merge",
|
|
1126
|
+
stashApplied: stashed
|
|
1127
|
+
};
|
|
1128
|
+
}, wsPath);
|
|
1129
|
+
return this.mergeCard(cardId, wsPath);
|
|
1130
|
+
}
|
|
1131
|
+
if (step === "wt-merge") {
|
|
1132
|
+
if (await unmergedPaths(wt) !== "") {
|
|
1133
|
+
if (!await this.resolveConflictsIn(cardId, wsPath, wt)) return false;
|
|
1134
|
+
const add = await runGit(wt, ["add", "-A"]);
|
|
1135
|
+
if (add.code !== 0) {
|
|
1136
|
+
await this.fail(cardId, "merge_failed", `git add 失败: ${add.stderr}`, "completed");
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
const commit = await runGit(wt, [
|
|
1140
|
+
"commit",
|
|
1141
|
+
"-m",
|
|
1142
|
+
`kanban: 合并 ${base} 到 ${branch}(任务 ${cardId.slice(0, 8)})`
|
|
1143
|
+
]);
|
|
1144
|
+
if (commit.code !== 0) {
|
|
1145
|
+
await this.fail(cardId, "merge_failed", `提交冲突解决结果失败: ${commit.stderr}`, "completed");
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
} else {
|
|
1149
|
+
const wtStatus = await runGit(wt, ["status", "--porcelain"]);
|
|
1150
|
+
if (wtStatus.code === 0 && wtStatus.stdout.trim() !== "") {
|
|
1151
|
+
const add = await runGit(wt, ["add", "-A"]);
|
|
1152
|
+
if (add.code !== 0) {
|
|
1153
|
+
await this.fail(cardId, "merge_failed", `git add 失败: ${add.stderr}`, "completed");
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
const commit = await runGit(wt, [
|
|
1157
|
+
"commit",
|
|
1158
|
+
"-m",
|
|
1159
|
+
`kanban: phase 实现成果(任务 ${cardId.slice(0, 8)})`
|
|
1160
|
+
]);
|
|
1161
|
+
if (commit.code !== 0) {
|
|
1162
|
+
await this.fail(cardId, "merge_failed", `提交 phase 成果失败: ${commit.stderr}`, "completed");
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const r = await runGit(wt, [
|
|
1167
|
+
"merge",
|
|
1168
|
+
base,
|
|
1169
|
+
"--no-edit"
|
|
1170
|
+
]);
|
|
1171
|
+
if (r.code !== 0) {
|
|
1172
|
+
if (await unmergedPaths(wt) !== "") {
|
|
1173
|
+
if (!await this.resolveConflictsIn(cardId, wsPath, wt)) return false;
|
|
1174
|
+
const add = await runGit(wt, ["add", "-A"]);
|
|
1175
|
+
if (add.code !== 0) {
|
|
1176
|
+
await this.fail(cardId, "merge_failed", `git add 失败: ${add.stderr}`, "completed");
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
const commit = await runGit(wt, [
|
|
1180
|
+
"commit",
|
|
1181
|
+
"-m",
|
|
1182
|
+
`kanban: 合并 ${base} 到 ${branch}(任务 ${cardId.slice(0, 8)})`
|
|
1183
|
+
]);
|
|
1184
|
+
if (commit.code !== 0) {
|
|
1185
|
+
await this.fail(cardId, "merge_failed", `提交冲突解决结果失败: ${commit.stderr}`, "completed");
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
} else {
|
|
1189
|
+
await this.fail(cardId, "merge_failed", `worktree 合入 ${base} 失败: ${r.stderr}`, "completed");
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
await this.store.mutate(cardId, (c) => {
|
|
1195
|
+
c.merge = {
|
|
1196
|
+
step: "ws-merge",
|
|
1197
|
+
stashApplied: c.merge?.stashApplied ?? false
|
|
1198
|
+
};
|
|
1199
|
+
}, wsPath);
|
|
1200
|
+
return this.mergeCard(cardId, wsPath);
|
|
1201
|
+
}
|
|
1202
|
+
if (step === "ws-merge") {
|
|
1203
|
+
const r = await runGit(wsPath, [
|
|
1204
|
+
"merge",
|
|
1205
|
+
branch,
|
|
1206
|
+
"--no-edit"
|
|
1207
|
+
]);
|
|
1208
|
+
let mergeCommit = await revParse(wsPath, "HEAD");
|
|
1209
|
+
if (r.code !== 0) {
|
|
1210
|
+
if (await unmergedPaths(wsPath) !== "") {
|
|
1211
|
+
if (!await this.resolveConflictsIn(cardId, wsPath, wsPath)) return false;
|
|
1212
|
+
const add = await runGit(wsPath, ["add", "-A"]);
|
|
1213
|
+
if (add.code !== 0) {
|
|
1214
|
+
await this.fail(cardId, "merge_failed", `git add 失败: ${add.stderr}`, "completed");
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
const commit = await runGit(wsPath, [
|
|
1218
|
+
"commit",
|
|
1219
|
+
"--no-edit",
|
|
1220
|
+
"-m",
|
|
1221
|
+
`kanban: 合并 ${branch} 到 ${base}(任务 ${cardId.slice(0, 8)})`
|
|
1222
|
+
]);
|
|
1223
|
+
if (commit.code !== 0) {
|
|
1224
|
+
await this.fail(cardId, "merge_failed", `提交合并失败: ${commit.stderr}`, "completed");
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
mergeCommit = await revParse(wsPath, "HEAD");
|
|
1228
|
+
} else {
|
|
1229
|
+
await this.fail(cardId, "merge_failed", `合入主分支失败: ${r.stderr}`, "completed");
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
await this.store.mutate(cardId, (c) => {
|
|
1234
|
+
c.merge = {
|
|
1235
|
+
step: "pop",
|
|
1236
|
+
stashApplied: c.merge?.stashApplied ?? false,
|
|
1237
|
+
mergeCommit
|
|
1238
|
+
};
|
|
1239
|
+
}, wsPath);
|
|
1240
|
+
return this.mergeCard(cardId, wsPath);
|
|
1241
|
+
}
|
|
1242
|
+
if (step === "pop") {
|
|
1243
|
+
if (await hasStashMessage(wsPath, stashMsg)) {
|
|
1244
|
+
const r = await runGit(wsPath, ["stash", "pop"]);
|
|
1245
|
+
if (r.code !== 0) {
|
|
1246
|
+
if (await unmergedPaths(wsPath) !== "") {
|
|
1247
|
+
if (!await this.resolveConflictsIn(cardId, wsPath, wsPath, { keepUnstaged: true })) return false;
|
|
1248
|
+
const check = await runGit(wsPath, ["diff", "--check"]);
|
|
1249
|
+
if (check.code !== 0) {
|
|
1250
|
+
await this.fail(cardId, "merge_failed", `stash pop 冲突解决后仍有残留问题: ${check.stderr}`, "completed");
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
await runGit(wsPath, ["stash", "drop"]);
|
|
1254
|
+
} else {
|
|
1255
|
+
await this.fail(cardId, "merge_failed", `stash pop 失败: ${r.stderr}`, "completed");
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
await this.store.mutate(cardId, (c) => {
|
|
1261
|
+
c.merge = {
|
|
1262
|
+
step: "cleanup",
|
|
1263
|
+
stashApplied: false,
|
|
1264
|
+
mergeCommit: c.merge?.mergeCommit
|
|
1265
|
+
};
|
|
1266
|
+
}, wsPath);
|
|
1267
|
+
return this.mergeCard(cardId, wsPath);
|
|
1268
|
+
}
|
|
1269
|
+
await runGit(wsPath, [
|
|
1270
|
+
"worktree",
|
|
1271
|
+
"remove",
|
|
1272
|
+
"--force",
|
|
1273
|
+
wt
|
|
1274
|
+
]);
|
|
1275
|
+
if (card.branch !== void 0) await runGit(wsPath, [
|
|
1276
|
+
"branch",
|
|
1277
|
+
"-D",
|
|
1278
|
+
card.branch
|
|
1279
|
+
]);
|
|
1280
|
+
if (await hasStashMessage(wsPath, stashMsg)) await runGit(wsPath, ["stash", "drop"]);
|
|
1281
|
+
await this.store.mutate(cardId, (c) => {
|
|
1282
|
+
c.worktreePath = void 0;
|
|
1283
|
+
c.merge = {
|
|
1284
|
+
step: "cleanup",
|
|
1285
|
+
stashApplied: false,
|
|
1286
|
+
mergeCommit: c.merge?.mergeCommit
|
|
1287
|
+
};
|
|
1288
|
+
}, wsPath);
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
async runMergeSession(cardId, wsPath, workdir, conflicts) {
|
|
1292
|
+
const sessionId = this.newSessionId();
|
|
1293
|
+
this.sessionCards.set(sessionId, cardId);
|
|
1294
|
+
await this.store.mutate(cardId, (c) => {
|
|
1295
|
+
c.sessions.merge.push(sessionId);
|
|
1296
|
+
}, wsPath);
|
|
1297
|
+
const route = await this.modelRoute(cardId, wsPath);
|
|
1298
|
+
try {
|
|
1299
|
+
const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver());
|
|
1300
|
+
this.keepHandle(cardId, handle);
|
|
1301
|
+
handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)));
|
|
1302
|
+
await handle.agent.whenIdle();
|
|
1303
|
+
const done = this.completed.get(sessionId) === true;
|
|
1304
|
+
this.completed.delete(sessionId);
|
|
1305
|
+
if (!done) {
|
|
1306
|
+
try {
|
|
1307
|
+
const tail = handle.agent.session.events.slice(-6).map((e) => ({
|
|
1308
|
+
type: e.type,
|
|
1309
|
+
...e.data !== void 0 ? { data: JSON.stringify(e.data).slice(0, 500) } : {}
|
|
1310
|
+
}));
|
|
1311
|
+
console.error("[task-kanban] merge session tail:", JSON.stringify(tail, null, 1));
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
console.error("[task-kanban] merge session events unreadable:", String(error));
|
|
1314
|
+
}
|
|
1315
|
+
await this.fail(cardId, "merge_failed", "合并会话结束但未调用 kanban_merge_resolved 声明解决", "completed");
|
|
1316
|
+
}
|
|
1317
|
+
return done;
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
await this.fail(cardId, "merge_failed", `合并会话失败: ${String(error)}`, "completed");
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async ensureGitignoreDsh(workspacePath) {
|
|
1324
|
+
if (!await isGitRepo(workspacePath)) return;
|
|
1325
|
+
const ignorePath = join(workspacePath, ".gitignore");
|
|
1326
|
+
let content = "";
|
|
1327
|
+
try {
|
|
1328
|
+
content = await readFile(ignorePath, "utf8");
|
|
1329
|
+
} catch {
|
|
1330
|
+
content = "";
|
|
1331
|
+
}
|
|
1332
|
+
if (content.includes(".dsh")) return;
|
|
1333
|
+
const extra = (content === "" ? "" : "\n") + "# @fonlan/dsh-task-kanban\n.dsh/\n";
|
|
1334
|
+
try {
|
|
1335
|
+
await appendFile(ignorePath, extra);
|
|
1336
|
+
} catch {}
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
//#endregion
|
|
1340
|
+
//#region src/server/models.ts
|
|
1341
|
+
/** Available models from every registered LLM provider (advisory catalog). */
|
|
1342
|
+
async function listModels(ctx) {
|
|
1343
|
+
const llm = ctx.get("llm");
|
|
1344
|
+
if (llm === void 0) return [];
|
|
1345
|
+
const out = [];
|
|
1346
|
+
for (const provider of llm.listProviders()) try {
|
|
1347
|
+
const models = await llm.listModels(provider.id);
|
|
1348
|
+
for (const m of models) out.push({
|
|
1349
|
+
provider: provider.id,
|
|
1350
|
+
id: m.id,
|
|
1351
|
+
name: m.name ?? m.id
|
|
1352
|
+
});
|
|
1353
|
+
} catch {}
|
|
1354
|
+
return out;
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/server/rpc.ts
|
|
1358
|
+
const API_PREFIX = "/plugins/@fonlan/dsh-task-kanban/api";
|
|
1359
|
+
function header(headers, name) {
|
|
1360
|
+
const value = headers[name];
|
|
1361
|
+
return typeof value === "string" ? value : void 0;
|
|
1362
|
+
}
|
|
1363
|
+
function isLoopbackHostname(hostname) {
|
|
1364
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
1365
|
+
const parts = hostname.split(".");
|
|
1366
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
|
|
1367
|
+
}
|
|
1368
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
1369
|
+
const host = header(req.headers, "host");
|
|
1370
|
+
if (host === void 0) return false;
|
|
1371
|
+
let hostUrl;
|
|
1372
|
+
try {
|
|
1373
|
+
hostUrl = new URL(`http://${host}`);
|
|
1374
|
+
} catch {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
|
|
1378
|
+
if (!trustedHosts.some((entry) => entry === hostUrl.hostname || entry === hostUrl.host)) return false;
|
|
1379
|
+
}
|
|
1380
|
+
if (header(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
1381
|
+
const origin = header(req.headers, "origin");
|
|
1382
|
+
if (origin === void 0) return true;
|
|
1383
|
+
try {
|
|
1384
|
+
return new URL(origin).host === hostUrl.host;
|
|
1385
|
+
} catch {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function trustedHostsOf(ctx) {
|
|
1390
|
+
for (const entry of ctx.get("loader")?.entries?.() ?? []) if (entry.options?.name === "connection") return entry.options.config?.trustedHosts ?? [];
|
|
1391
|
+
return [];
|
|
1392
|
+
}
|
|
1393
|
+
function writeJson(res, body) {
|
|
1394
|
+
res.writeHead(200, {
|
|
1395
|
+
"content-type": "application/json; charset=utf-8",
|
|
1396
|
+
"cache-control": "no-store"
|
|
1397
|
+
});
|
|
1398
|
+
res.end(JSON.stringify(body));
|
|
1399
|
+
}
|
|
1400
|
+
function writeError(res, code, message, status = 400) {
|
|
1401
|
+
res.writeHead(status, {
|
|
1402
|
+
"content-type": "application/json; charset=utf-8",
|
|
1403
|
+
"cache-control": "no-store"
|
|
1404
|
+
});
|
|
1405
|
+
res.end(JSON.stringify({
|
|
1406
|
+
ok: false,
|
|
1407
|
+
error: {
|
|
1408
|
+
code,
|
|
1409
|
+
message
|
|
1410
|
+
}
|
|
1411
|
+
}));
|
|
1412
|
+
}
|
|
1413
|
+
function requireString(value, name) {
|
|
1414
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} 不能为空`);
|
|
1415
|
+
return value;
|
|
1416
|
+
}
|
|
1417
|
+
async function readJsonBody(req) {
|
|
1418
|
+
const chunks = [];
|
|
1419
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1420
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1421
|
+
if (raw.trim() === "") return {};
|
|
1422
|
+
try {
|
|
1423
|
+
const parsed = JSON.parse(raw);
|
|
1424
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
1425
|
+
} catch {
|
|
1426
|
+
return {};
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
function registerApiRoutes(ctx, runner, settings) {
|
|
1430
|
+
const fence = (req) => isTrustedApiRequest(req, trustedHostsOf(ctx));
|
|
1431
|
+
const disposers = [];
|
|
1432
|
+
const route = (name, handler) => {
|
|
1433
|
+
disposers.push(ctx.webServer.register({
|
|
1434
|
+
kind: "prefix",
|
|
1435
|
+
path: `${API_PREFIX}/${name}`,
|
|
1436
|
+
handler: async (req, res) => {
|
|
1437
|
+
if (!fence(req)) {
|
|
1438
|
+
writeError(res, "forbidden", "forbidden", 403);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
if (req.method !== "POST" && req.method !== "GET") {
|
|
1442
|
+
writeError(res, "method", "method not allowed", 405);
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const payload = await readJsonBody(req);
|
|
1446
|
+
try {
|
|
1447
|
+
writeJson(res, {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
value: await handler(payload)
|
|
1450
|
+
});
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
writeError(res, "error", error instanceof Error ? error.message : String(error));
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}));
|
|
1456
|
+
};
|
|
1457
|
+
route("list", async (p) => runner.listCards(requireString(p.workspacePath, "workspacePath")));
|
|
1458
|
+
route("create", async (p) => {
|
|
1459
|
+
const workspacePath = requireString(p.workspacePath, "workspacePath");
|
|
1460
|
+
const requirement = requireString(p.requirement, "requirement");
|
|
1461
|
+
const model = typeof p.model === "string" ? p.model : "";
|
|
1462
|
+
const provider = typeof p.provider === "string" ? p.provider : void 0;
|
|
1463
|
+
return runner.createTask({
|
|
1464
|
+
workspacePath,
|
|
1465
|
+
requirement,
|
|
1466
|
+
model,
|
|
1467
|
+
provider
|
|
1468
|
+
});
|
|
1469
|
+
});
|
|
1470
|
+
route("move", async (p) => {
|
|
1471
|
+
const cardId = requireString(p.cardId, "cardId");
|
|
1472
|
+
const toLane = p.toLane;
|
|
1473
|
+
if (![
|
|
1474
|
+
"demand",
|
|
1475
|
+
"queue",
|
|
1476
|
+
"running",
|
|
1477
|
+
"completed",
|
|
1478
|
+
"merged"
|
|
1479
|
+
].includes(toLane)) throw new Error("无效的目标泳道");
|
|
1480
|
+
return runner.moveTask(cardId, toLane);
|
|
1481
|
+
});
|
|
1482
|
+
route("stop", async (p) => runner.stopTask(requireString(p.cardId, "cardId")));
|
|
1483
|
+
route("retry", async (p) => runner.retryTask(requireString(p.cardId, "cardId")));
|
|
1484
|
+
route("remove", async (p) => runner.deleteTask(requireString(p.cardId, "cardId")));
|
|
1485
|
+
route("settings.get", async () => settings.get());
|
|
1486
|
+
route("settings.set", async (p) => {
|
|
1487
|
+
const patch = {};
|
|
1488
|
+
if (typeof p.maxParallelWorkers === "number") patch.maxParallelWorkers = p.maxParallelWorkers;
|
|
1489
|
+
if (typeof p.defaultModel === "string") patch.defaultModel = p.defaultModel;
|
|
1490
|
+
await settings.update(patch);
|
|
1491
|
+
return settings.get();
|
|
1492
|
+
});
|
|
1493
|
+
route("models.list", async () => listModels(ctx));
|
|
1494
|
+
return () => {
|
|
1495
|
+
for (const d of disposers) d();
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region src/index.ts
|
|
1500
|
+
const name = "@fonlan/dsh-task-kanban";
|
|
1501
|
+
const inject = ["webServer", "agents"];
|
|
1502
|
+
const Config = z.object({});
|
|
1503
|
+
function apply(ctx, _config) {
|
|
1504
|
+
const settings = registerSettings(ctx);
|
|
1505
|
+
const runner = new KanbanRunner(ctx, new TaskStore(), settings);
|
|
1506
|
+
ctx.effect(() => {
|
|
1507
|
+
runner.recoverInterrupted();
|
|
1508
|
+
runner.start();
|
|
1509
|
+
return () => runner.stop();
|
|
1510
|
+
}, "task-kanban: runner lifecycle");
|
|
1511
|
+
ctx.effect(() => registerApiRoutes(ctx, runner, settings), "task-kanban: api routes");
|
|
1512
|
+
}
|
|
1513
|
+
//#endregion
|
|
1514
|
+
export { Config, apply, inject, name };
|