@baique/pi-web-sky 0.1.56 → 0.1.57
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +5 -5
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +2 -2
- package/.next/required-server-files.js +3 -2
- package/.next/required-server-files.json +3 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/bash-output/route.js +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/running/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/board-assets/[...path]/route.js +1 -1
- package/.next/server/app/api/board-assets/route.js +1 -1
- package/.next/server/app/api/boards/[id]/add-session/route.js +1 -1
- package/.next/server/app/api/boards/[id]/reconcile/route.js +1 -1
- package/.next/server/app/api/boards/[id]/route.js +1 -1
- package/.next/server/app/api/boards/reorder/route.js +1 -1
- package/.next/server/app/api/boards/route.js +1 -1
- package/.next/server/app/api/cwd/validate/route.js +1 -1
- package/.next/server/app/api/default-cwd/route.js +1 -1
- package/.next/server/app/api/file-index/route.js +1 -1
- package/.next/server/app/api/files/[...path]/route.js +2 -2
- package/.next/server/app/api/mcp/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/search/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/export/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/summary/route.js +1 -1
- package/.next/server/app/api/skills/install/route.js +1 -1
- package/.next/server/app/api/subagents/[id]/route.js +1 -1
- package/.next/server/app/api/subagents/profiles/route.js +1 -1
- package/.next/server/app/api/task-card-questions/[id]/answer/route.js +1 -1
- package/.next/server/app/api/task-card-questions/route.js +1 -1
- package/.next/server/app/api/task-cards/[id]/route.js +1 -1
- package/.next/server/app/api/task-cards/route.js +1 -1
- package/.next/server/app/api/task-cards/unbind/route.js +1 -1
- package/.next/server/app/api/task-scheduler/status/route.js +1 -1
- package/.next/server/app/api/tasks/[id]/assign-session/route.js +2 -12
- package/.next/server/app/api/tasks/[id]/board/route.js +1 -1
- package/.next/server/app/api/tasks/[id]/route.js +1 -1
- package/.next/server/app/api/tasks/reorder/route.js +2 -12
- package/.next/server/app/api/tasks/route.js +2 -12
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +10 -10
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +5 -5
- package/.next/server/chunks/1937.js +14 -13
- package/.next/server/chunks/2524.js +2 -2
- package/.next/server/chunks/3500.js +1 -1
- package/.next/server/chunks/402.js +21 -10
- package/.next/server/chunks/4457.js +3 -13
- package/.next/server/chunks/542.js +8 -8
- package/.next/server/chunks/5794.js +1 -1
- package/.next/server/chunks/5940.js +1 -1
- package/.next/server/chunks/6429.js +4 -4
- package/.next/server/chunks/6525.js +18 -7
- package/.next/server/chunks/6583.js +12 -11
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/752.bafff304e6b00987.js +1 -0
- package/.next/static/chunks/8088.39866dc5f1d15868.js +1 -0
- package/.next/static/chunks/app/{layout-87087b955e0c9507.js → layout-fae2b226b3c78448.js} +1 -1
- package/.next/static/chunks/app/page-d5af3bd535589daf.js +256 -0
- package/.next/static/chunks/{webpack-4de8e8dd9f2f3173.js → webpack-1ce35e1249cfd931.js} +1 -1
- package/.next/static/css/{c6d32b6031181be7.css → c7d127e07b1a786c.css} +1 -1
- package/.next/trace +8 -7
- package/.next/trace-build +1 -1
- package/lib/board-reconcile.test.mjs +26 -8
- package/lib/board-reconcile.ts +3 -1
- package/lib/board-store.test.mjs +2 -2
- package/lib/ime-guard.test.mjs +52 -0
- package/lib/ime-guard.ts +54 -0
- package/lib/rpc-manager.activity.test.mjs +223 -0
- package/lib/rpc-manager.test.mjs +22 -0
- package/lib/rpc-manager.title.test.mjs +152 -0
- package/lib/rpc-manager.ts +95 -8
- package/lib/session-activity.test.mjs +123 -0
- package/lib/session-activity.ts +77 -0
- package/lib/session-delete.test.mjs +45 -41
- package/lib/session-delete.ts +8 -34
- package/lib/session-index-scanner.test.mjs +364 -2
- package/lib/session-index-scanner.ts +162 -57
- package/lib/session-reader.noscan.test.mjs +371 -0
- package/lib/session-reader.project.test.mjs +30 -52
- package/lib/session-reader.test.mjs +2 -65
- package/lib/session-reader.ts +177 -213
- package/lib/session-scanner.test.mjs +151 -38
- package/lib/session-scanner.ts +140 -61
- package/lib/session-search.ts +6 -5
- package/lib/sqlite-db.test.mjs +26 -0
- package/lib/sqlite-db.ts +10 -1
- package/lib/subagent-runtime.ts +42 -0
- package/lib/subagent-session-title.test.mjs +120 -0
- package/lib/task-scheduler.test.mjs +27 -0
- package/lib/task-scheduler.ts +8 -1
- package/lib/task-store.activity.test.mjs +141 -0
- package/lib/task-store.meta-row.test.mjs +25 -5
- package/lib/task-store.partial-row.test.mjs +198 -0
- package/lib/task-store.subtree.test.mjs +172 -0
- package/lib/task-store.test.mjs +46 -20
- package/lib/task-store.ts +344 -68
- package/next.config.ts +3 -1
- package/package.json +1 -1
- package/.next/static/chunks/752.adce8bc3ebbaf4b6.js +0 -1
- package/.next/static/chunks/8088.0cad1ff2e09fd8ff.js +0 -1
- package/.next/static/chunks/app/page-efca6bf51a5f8ccb.js +0 -256
- /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_buildManifest.js +0 -0
- /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_ssgManifest.js +0 -0
- /package/lib/{subagent-isolation.test.mjs → worktree-isolation.test.mjs} +0 -0
package/lib/subagent-runtime.ts
CHANGED
|
@@ -38,6 +38,8 @@ import { resolveShellTools } from "./powershell-settings";
|
|
|
38
38
|
import { isBuiltInSubagentsEnabled, readSubagentSettings } from "./subagent-settings";
|
|
39
39
|
import { SubagentQueue } from "./subagent-queue";
|
|
40
40
|
import { addWorktree, removeWorktree } from "./worktree";
|
|
41
|
+
import { projectIdentityKey } from "./project-identity";
|
|
42
|
+
import { ensureSessionMetaRow, taskForSession } from "./task-store";
|
|
41
43
|
import { randomUUID } from "node:crypto";
|
|
42
44
|
|
|
43
45
|
interface HostSession {
|
|
@@ -129,6 +131,32 @@ async function cleanupWorktree(
|
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/** 内置 subagent 出生即建行(父会话归属 + 任务归属,否则只会在聊天区当孤儿根出现)。
|
|
135
|
+
* `title` **只**取 `description`(用户 2026-09-18 规则:描述有值即会话标题,展示优先于首条消息);
|
|
136
|
+
* 空/全空白不写 title——既不写空串、也不拿 run 元数据里的 profile 显示名兜底(展示链是
|
|
137
|
+
* `title || first_message`,拿 "Explore" 当标题会把被委派任务的原文挤掉,正是要修的病)。
|
|
138
|
+
* `first_message` 是内容派生列,这里**不写**:留给扫描器/事件链路按真实首条用户消息回填,
|
|
139
|
+
* 否则「首条消息」会变成 describe 文案而不是被委派的任务。 */
|
|
140
|
+
export function ensureSubagentSessionRow(sessionId: string, row: {
|
|
141
|
+
path: string;
|
|
142
|
+
cwd: string;
|
|
143
|
+
projectKey: string;
|
|
144
|
+
parentId: string;
|
|
145
|
+
taskId?: string | null;
|
|
146
|
+
/** Agent 工具传来的短描述(request.description)——有值即标题。 */
|
|
147
|
+
description: string;
|
|
148
|
+
}): void {
|
|
149
|
+
const title = row.description.trim();
|
|
150
|
+
ensureSessionMetaRow(sessionId, {
|
|
151
|
+
path: row.path,
|
|
152
|
+
cwd: row.cwd,
|
|
153
|
+
projectKey: row.projectKey,
|
|
154
|
+
parentId: row.parentId,
|
|
155
|
+
taskId: row.taskId,
|
|
156
|
+
...(title ? { title } : {}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
132
160
|
export function createSubagentController(
|
|
133
161
|
dependencies: SubagentRuntimeDependencies,
|
|
134
162
|
): SubagentController {
|
|
@@ -258,6 +286,20 @@ export function createSubagentController(
|
|
|
258
286
|
chatOnly,
|
|
259
287
|
});
|
|
260
288
|
|
|
289
|
+
// 子代理会话与 fork 同规则:落库即带父会话归属(否则只会在聊天区当孤儿根出现)。
|
|
290
|
+
try {
|
|
291
|
+
ensureSubagentSessionRow(inner.sessionId, {
|
|
292
|
+
path: inner.sessionFile ?? "",
|
|
293
|
+
cwd: (isolatedWorktree ? childCwd : parent.cwd) ?? "",
|
|
294
|
+
projectKey: projectIdentityKey((isolatedWorktree ? childCwd : parent.cwd) ?? ""),
|
|
295
|
+
parentId: parentSessionId,
|
|
296
|
+
taskId: taskForSession(parentSessionId),
|
|
297
|
+
description: request.description,
|
|
298
|
+
});
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.error("[pi-web] subagent 会话建行失败:", e instanceof Error ? e.message : String(e));
|
|
301
|
+
}
|
|
302
|
+
|
|
261
303
|
const initialRun: SubagentRunInfo = {
|
|
262
304
|
sessionId: inner.sessionId,
|
|
263
305
|
sessionPath: inner.sessionFile ?? sessionManager.getSessionFile() ?? "",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test, { after } from "node:test";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { createJiti } from "jiti";
|
|
9
|
+
|
|
10
|
+
// getAgentDir() 每次调用都读环境变量 → 指向临时树,测试绝不碰 ~/.pi/agent,也不跑真实 subagent。
|
|
11
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-web-subagent-title-"));
|
|
12
|
+
mkdirSync(join(agentDir, "sessions"), { recursive: true });
|
|
13
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
14
|
+
after(() => rmSync(agentDir, { recursive: true, force: true }));
|
|
15
|
+
|
|
16
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
|
|
17
|
+
const { getDb, setDbForTesting } = await jiti.import("./sqlite-db.ts");
|
|
18
|
+
const { fillFirstMessageIfEmpty } = await jiti.import("./task-store.ts");
|
|
19
|
+
const { ensureSubagentSessionRow } = await jiti.import("./subagent-runtime.ts");
|
|
20
|
+
|
|
21
|
+
function freshDb() {
|
|
22
|
+
setDbForTesting(new DatabaseSync(":memory:"));
|
|
23
|
+
const db = getDb();
|
|
24
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 内置 subagent 出生即建行,与 lib/subagent-runtime.ts start() 里同参数。
|
|
29
|
+
* 只收 request.description:run 元数据里的 profile 显示名**不是**标题来源。 */
|
|
30
|
+
function buildRow(db, sessionId, { description, parentId = "parent-session" }) {
|
|
31
|
+
ensureSubagentSessionRow(sessionId, {
|
|
32
|
+
path: `/tmp/sessions/${sessionId}.jsonl`,
|
|
33
|
+
cwd: "/tmp",
|
|
34
|
+
projectKey: "proj",
|
|
35
|
+
parentId,
|
|
36
|
+
taskId: null,
|
|
37
|
+
description,
|
|
38
|
+
});
|
|
39
|
+
return db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get(sessionId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("description 有值即标题:建行时 title = trim 后的 description(不依赖首条消息兜底)", () => {
|
|
43
|
+
const db = freshDb();
|
|
44
|
+
const row = buildRow(db, "child-named", { description: " Inspect parser " });
|
|
45
|
+
|
|
46
|
+
assert.equal(row.title, "Inspect parser");
|
|
47
|
+
assert.equal(row.parent_id, "parent-session");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("description 空白/空 → title 为 NULL(不写空串;profile 显示名也不许兜底成标题)", () => {
|
|
51
|
+
const db = freshDb();
|
|
52
|
+
|
|
53
|
+
// 空 description 的子代理,run 元数据里仍有 profile.displayName("Explore"/"Plan" 之类)。
|
|
54
|
+
// 建行时只拿得到 request.description,标题必须是 NULL——展示回退到被委派任务的原文,
|
|
55
|
+
// 而不是显示 "Explore"。
|
|
56
|
+
for (const [sessionId, description] of [["child-blank", " "], ["child-empty", ""], ["child-newline", " \n "]]) {
|
|
57
|
+
const row = buildRow(db, sessionId, { description });
|
|
58
|
+
assert.equal(row.title, null, `${sessionId} 不应写入空串/空白/profile 名标题`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("老兜底口已关闭:实参里就算带上元数据描述,空 description 的 title 仍为 NULL", () => {
|
|
63
|
+
const db = freshDb();
|
|
64
|
+
// 元数据描述(metadata.description = profile.displayName)曾是标题兜底来源,
|
|
65
|
+
// 现在建行函数不认这个字段——它不得从任何旁路挤进 title。
|
|
66
|
+
ensureSubagentSessionRow("child-extra", {
|
|
67
|
+
path: "/tmp/sessions/child-extra.jsonl",
|
|
68
|
+
cwd: "/tmp",
|
|
69
|
+
projectKey: "proj",
|
|
70
|
+
parentId: "parent-session",
|
|
71
|
+
taskId: null,
|
|
72
|
+
description: " ",
|
|
73
|
+
fallbackDescription: "Explore",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const row = db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get("child-extra");
|
|
77
|
+
assert.equal(row.title, null, "profile 显示名不得变成子会话标题");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("first_message 不再写 description:留 NULL,由真实首条用户消息回填", () => {
|
|
81
|
+
const db = freshDb();
|
|
82
|
+
const row = buildRow(db, "child-first", { description: "Inspect parser" });
|
|
83
|
+
|
|
84
|
+
assert.equal(row.first_message, null, "描述不是首条消息,不应占用内容派生列");
|
|
85
|
+
|
|
86
|
+
// 内容派生列的回填路径(扫描器/事件链路同语义)在其上照常工作。
|
|
87
|
+
fillFirstMessageIfEmpty("child-first", "Find the parser");
|
|
88
|
+
const filled = db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get("child-first");
|
|
89
|
+
assert.equal(filled.first_message, "Find the parser");
|
|
90
|
+
assert.equal(filled.title, "Inspect parser", "回填首条消息不动自定义标题");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** 从 `ensureSubagentSessionRow(inner.sessionId` 起按花括号配对取出实参块。 */
|
|
94
|
+
function subagentRowCallArgs(source) {
|
|
95
|
+
const start = source.indexOf("ensureSubagentSessionRow(inner.sessionId");
|
|
96
|
+
assert.ok(start >= 0, "start() 里找不到 ensureSubagentSessionRow(inner.sessionId 调用点");
|
|
97
|
+
const open = source.indexOf("{", start);
|
|
98
|
+
assert.ok(open > start, "调用点后面找不到实参对象");
|
|
99
|
+
let depth = 0;
|
|
100
|
+
for (let i = open; i < source.length; i += 1) {
|
|
101
|
+
if (source[i] === "{") depth += 1;
|
|
102
|
+
else if (source[i] === "}") {
|
|
103
|
+
depth -= 1;
|
|
104
|
+
if (depth === 0) return source.slice(open, i + 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw new Error("建行调用的实参对象没有闭合");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test("start() 接线:标题来源只有 request.description,不传 firstMessage/元数据描述", async () => {
|
|
111
|
+
const source = await readFile(new URL("./subagent-runtime.ts", import.meta.url), "utf8");
|
|
112
|
+
const args = subagentRowCallArgs(source);
|
|
113
|
+
|
|
114
|
+
assert.match(args, /description: request\.description/, "标题来源必须是 request.description");
|
|
115
|
+
assert.doesNotMatch(args, /firstMessage/, "first_message 是内容派生列,建行时不能写");
|
|
116
|
+
assert.doesNotMatch(args, /fallbackDescription|displayName|metadata\.description/, "profile 显示名不算标题");
|
|
117
|
+
assert.match(args, /parentId: parentSessionId/, "父会话归属丢了,子会话会在聊天区当孤儿根");
|
|
118
|
+
assert.match(args, /taskId: taskForSession\(parentSessionId\)/, "任务归属继承丢了");
|
|
119
|
+
assert.doesNotMatch(source, /firstMessage: request\.description/, "描述不能占 first_message(那是内容派生列)");
|
|
120
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import test from "node:test";
|
|
3
4
|
import { DatabaseSync } from "node:sqlite";
|
|
4
5
|
import { createJiti } from "jiti";
|
|
@@ -76,3 +77,29 @@ test("clearSchedulerLeaderForTesting 清空后可重新注册", () => {
|
|
|
76
77
|
assert.equal(getLeaderInstanceId(), null);
|
|
77
78
|
assert.equal(tryBecomeLeader("inst-b"), true);
|
|
78
79
|
});
|
|
80
|
+
|
|
81
|
+
// ── 派发时的归属失败不能静默 ──
|
|
82
|
+
// assignSessionToTask 会拒绝(返回 false):祖先属别的任务 / 会话解析不出。丢了返回值的话
|
|
83
|
+
// 「派发了但会话没进任务区」在日志里毫无痕迹(reconcile 会把它当孤儿卡)。
|
|
84
|
+
|
|
85
|
+
const schedulerSource = await readFile(new URL("./task-scheduler.ts", import.meta.url), "utf8");
|
|
86
|
+
|
|
87
|
+
/** 取 [from, to) 之间的源码片段;锚点找不到就报错(避免静默退化成整文件切片)。 */
|
|
88
|
+
function sliceBetween(text, from, to) {
|
|
89
|
+
const start = text.indexOf(from);
|
|
90
|
+
assert.ok(start >= 0, `切片起点不存在:${from}`);
|
|
91
|
+
const end = text.indexOf(to, start);
|
|
92
|
+
assert.ok(end > start, `切片终点不存在(或不在起点之后):${to}`);
|
|
93
|
+
return text.slice(start, end);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("派发会话归属失败不静默:检查 assignSessionToTask 返回值并 console.warn", () => {
|
|
97
|
+
const assignBlock = sliceBetween(
|
|
98
|
+
schedulerSource,
|
|
99
|
+
"const board = getBoard(card.boardId);",
|
|
100
|
+
"if (session.session.isRunning())",
|
|
101
|
+
);
|
|
102
|
+
assert.match(assignBlock, /const assigned = await assignSessionToTask\(/, "返回值必须接住");
|
|
103
|
+
assert.match(assignBlock, /if \(!assigned\)[\s\S]*?console\.warn\(/, "失败必须可见(console.warn)");
|
|
104
|
+
assert.match(assignBlock, /派发的会话未能归属任务/);
|
|
105
|
+
});
|
package/lib/task-scheduler.ts
CHANGED
|
@@ -156,7 +156,14 @@ export async function dispatchCard(card: TaskCard): Promise<boolean> {
|
|
|
156
156
|
|
|
157
157
|
const board = getBoard(card.boardId);
|
|
158
158
|
if (board?.taskId) {
|
|
159
|
-
|
|
159
|
+
// 返回值不能丢:拒绝(祖先属别的任务 / 会话解析不出)时这个会话不会进任务区,
|
|
160
|
+
// 而 reconcile 按「卡在任务里」补卡——静默失败会让派发结果不可解释。
|
|
161
|
+
const assigned = await assignSessionToTask(session.realSessionId, board.taskId);
|
|
162
|
+
if (!assigned) {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[task-scheduler] #${card.number} ${card.name} 派发的会话未能归属任务 task=${board.taskId} session=${session.realSessionId}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
160
167
|
// 注意:不在 assignSessionToTask 后 reconcile——此刻卡 sessionId 未落表,
|
|
161
168
|
// reconcile 只能补会话卡、建不了 exec 线 → 造成「先出卡后出线」。
|
|
162
169
|
// 统一在 updateCard(sessionId) 之后触发(补卡+建线同一次事务、一次广播)。
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
|
|
6
|
+
const jiti = createJiti(import.meta.url, {
|
|
7
|
+
interopDefault: true,
|
|
8
|
+
moduleCache: false,
|
|
9
|
+
alias: { "@": process.cwd() },
|
|
10
|
+
});
|
|
11
|
+
const { setDbForTesting } = await jiti.import("./sqlite-db.ts");
|
|
12
|
+
const {
|
|
13
|
+
LAST_REPLY_MAX,
|
|
14
|
+
fillFirstMessageIfEmpty,
|
|
15
|
+
recordSessionOutcome,
|
|
16
|
+
touchSessionActivity,
|
|
17
|
+
} = await jiti.import("./task-store.ts");
|
|
18
|
+
|
|
19
|
+
function freshDb() {
|
|
20
|
+
const db = new DatabaseSync(":memory:");
|
|
21
|
+
setDbForTesting(db);
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 造一条「扫描器/创建链路建立」的完整行:写函数只允许改自己那一列。 */
|
|
26
|
+
function seedRow(db, sessionId) {
|
|
27
|
+
db.prepare(
|
|
28
|
+
`INSERT INTO session_meta
|
|
29
|
+
(session_id, task_id, updated, pinned, path, cwd, project_key, title, first_message, parent_id, created, modified, last_reply)
|
|
30
|
+
VALUES (?, NULL, 111, 0, ?, '/p', 'proj', '标题', NULL, NULL, 100, 200, NULL)`,
|
|
31
|
+
).run(sessionId, `/s/${sessionId}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rowOf = (db, sessionId) =>
|
|
35
|
+
db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get(sessionId);
|
|
36
|
+
const count = (db) => db.prepare("SELECT COUNT(*) AS c FROM session_meta").get().c;
|
|
37
|
+
|
|
38
|
+
test("touchSessionActivity 只刷 modified,不建行(无行时静默 no-op)", () => {
|
|
39
|
+
const db = freshDb();
|
|
40
|
+
seedRow(db, "s1");
|
|
41
|
+
|
|
42
|
+
touchSessionActivity("s1", 5000);
|
|
43
|
+
|
|
44
|
+
const row = rowOf(db, "s1");
|
|
45
|
+
assert.equal(row.modified, 5000);
|
|
46
|
+
// 其它列一律不动:活跃时间不是归属、不是上次改写时间、也不是内容。
|
|
47
|
+
assert.equal(row.updated, 111);
|
|
48
|
+
assert.equal(row.last_reply, null);
|
|
49
|
+
assert.equal(row.first_message, null);
|
|
50
|
+
assert.equal(row.path, "/s/s1.jsonl");
|
|
51
|
+
assert.equal(row.pinned, 0);
|
|
52
|
+
|
|
53
|
+
// 缺省参数 = 当前时间(事件链路不需要自己算时间戳)
|
|
54
|
+
const before = Date.now();
|
|
55
|
+
touchSessionActivity("s1");
|
|
56
|
+
assert.ok(rowOf(db, "s1").modified >= before);
|
|
57
|
+
|
|
58
|
+
// 库里没有这条会话 → 只影响 0 行,绝不因事件建局部行(V4 缺陷的回归锁)
|
|
59
|
+
touchSessionActivity("no-such-session", 9999);
|
|
60
|
+
assert.equal(count(db), 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("recordSessionOutcome 写 last_reply + modified,超 4000 字符截断", () => {
|
|
64
|
+
const db = freshDb();
|
|
65
|
+
seedRow(db, "s2");
|
|
66
|
+
|
|
67
|
+
recordSessionOutcome("s2", { lastReply: "最后一条回复", at: 7000 });
|
|
68
|
+
|
|
69
|
+
let row = rowOf(db, "s2");
|
|
70
|
+
assert.equal(row.last_reply, "最后一条回复");
|
|
71
|
+
assert.equal(row.modified, 7000);
|
|
72
|
+
assert.equal(row.updated, 111, "结果落库不当作一次归属改写");
|
|
73
|
+
|
|
74
|
+
const long = "x".repeat(LAST_REPLY_MAX + 100);
|
|
75
|
+
recordSessionOutcome("s2", { lastReply: long, at: 8000 });
|
|
76
|
+
row = rowOf(db, "s2");
|
|
77
|
+
assert.equal(row.last_reply.length, LAST_REPLY_MAX);
|
|
78
|
+
assert.equal(row.last_reply, long.slice(0, LAST_REPLY_MAX));
|
|
79
|
+
assert.equal(row.modified, 8000);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("recordSessionOutcome 空文本不清库:只刷 modified,保留上一条回复", () => {
|
|
83
|
+
const db = freshDb();
|
|
84
|
+
seedRow(db, "s3");
|
|
85
|
+
recordSessionOutcome("s3", { lastReply: "上一条回复", at: 1000 });
|
|
86
|
+
|
|
87
|
+
// 刚发出就被取消 / 整轮只有工具调用:不许把 last_reply 写成空串
|
|
88
|
+
recordSessionOutcome("s3", { lastReply: "", at: 9000 });
|
|
89
|
+
let row = rowOf(db, "s3");
|
|
90
|
+
assert.equal(row.last_reply, "上一条回复");
|
|
91
|
+
assert.equal(row.modified, 9000);
|
|
92
|
+
|
|
93
|
+
recordSessionOutcome("s3", { lastReply: " \n\t ", at: 9500 });
|
|
94
|
+
row = rowOf(db, "s3");
|
|
95
|
+
assert.equal(row.last_reply, "上一条回复", "纯空白同样不算一条消息");
|
|
96
|
+
|
|
97
|
+
// 从没存过回复的行:空文本保持 NULL(而不是被写成 "")
|
|
98
|
+
seedRow(db, "s3b");
|
|
99
|
+
recordSessionOutcome("s3b", { lastReply: "", at: 9600 });
|
|
100
|
+
assert.equal(rowOf(db, "s3b").last_reply, null);
|
|
101
|
+
|
|
102
|
+
// 无行 → 0 行,不建行
|
|
103
|
+
recordSessionOutcome("no-such-session", { lastReply: "x", at: 1 });
|
|
104
|
+
assert.equal(count(db), 2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("fillFirstMessageIfEmpty 只在为空时写入(已有 first_message 不被覆盖)", () => {
|
|
108
|
+
const db = freshDb();
|
|
109
|
+
seedRow(db, "s4");
|
|
110
|
+
|
|
111
|
+
fillFirstMessageIfEmpty("s4", "第一条用户消息");
|
|
112
|
+
assert.equal(rowOf(db, "s4").first_message, "第一条用户消息");
|
|
113
|
+
|
|
114
|
+
fillFirstMessageIfEmpty("s4", "后来的消息");
|
|
115
|
+
assert.equal(rowOf(db, "s4").first_message, "第一条用户消息", "首条消息只落一次");
|
|
116
|
+
assert.equal(rowOf(db, "s4").modified, 200, "回填首条消息不动活跃时间");
|
|
117
|
+
|
|
118
|
+
// '' 视为空(老行可能被写成空串)→ 允许回填
|
|
119
|
+
db.prepare("UPDATE session_meta SET first_message = '' WHERE session_id = 's4'").run();
|
|
120
|
+
fillFirstMessageIfEmpty("s4", "补上");
|
|
121
|
+
assert.equal(rowOf(db, "s4").first_message, "补上");
|
|
122
|
+
|
|
123
|
+
// 空白首条消息不写(避免把空串当成内容)
|
|
124
|
+
seedRow(db, "s5");
|
|
125
|
+
fillFirstMessageIfEmpty("s5", " ");
|
|
126
|
+
assert.equal(rowOf(db, "s5").first_message, null);
|
|
127
|
+
|
|
128
|
+
// 无行 → 0 行,不建行
|
|
129
|
+
fillFirstMessageIfEmpty("no-such-session", "x");
|
|
130
|
+
assert.equal(count(db), 2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("三个写函数一律只 UPDATE:库里没行时不留任何新行", () => {
|
|
134
|
+
const db = freshDb();
|
|
135
|
+
|
|
136
|
+
touchSessionActivity("ghost", 1);
|
|
137
|
+
recordSessionOutcome("ghost", { lastReply: "回复", at: 2 });
|
|
138
|
+
fillFirstMessageIfEmpty("ghost", "首条");
|
|
139
|
+
|
|
140
|
+
assert.equal(count(db), 0);
|
|
141
|
+
});
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test, { after } from "node:test";
|
|
3
6
|
import { DatabaseSync } from "node:sqlite";
|
|
4
7
|
import { createJiti } from "jiti";
|
|
5
8
|
|
|
9
|
+
// getAgentDir() 每次调用都读环境变量 → 指向临时树,测试绝不碰 ~/.pi/agent。
|
|
10
|
+
// sessions 子目录先建好:无行会话补行时会按 id 在这里找文件,miss 不回落扫开发机真实目录。
|
|
11
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-web-meta-row-"));
|
|
12
|
+
mkdirSync(join(agentDir, "sessions"), { recursive: true });
|
|
13
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
14
|
+
after(() => rmSync(agentDir, { recursive: true, force: true }));
|
|
15
|
+
|
|
6
16
|
const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
|
|
7
17
|
const { getDb, setDbForTesting } = await jiti.import("./sqlite-db.ts");
|
|
8
18
|
const { ensureSessionMetaRow, assignSessionToTask, setSessionTitle, unassignSession, createTask } = await jiti.import("./task-store.ts");
|
|
@@ -34,7 +44,7 @@ test("ensureSessionMetaRow 建全列索引行(新建会话落盘即建行)",
|
|
|
34
44
|
assert.ok(row.modified > 0);
|
|
35
45
|
});
|
|
36
46
|
|
|
37
|
-
test("ensureSessionMetaRow 后 assignSessionToTask 只补归属、不破坏索引列", () => {
|
|
47
|
+
test("ensureSessionMetaRow 后 assignSessionToTask 只补归属、不破坏索引列", async () => {
|
|
38
48
|
const db = freshDb();
|
|
39
49
|
ensureSessionMetaRow("sess-2", {
|
|
40
50
|
path: "/p2/sessions/sess-2.jsonl",
|
|
@@ -42,27 +52,37 @@ test("ensureSessionMetaRow 后 assignSessionToTask 只补归属、不破坏索
|
|
|
42
52
|
projectKey: "p2",
|
|
43
53
|
});
|
|
44
54
|
const task = createTask("p2", "t");
|
|
45
|
-
assert.equal(assignSessionToTask("sess-2", task.id), true);
|
|
55
|
+
assert.equal(await assignSessionToTask("sess-2", task.id), true);
|
|
46
56
|
const row = db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get("sess-2");
|
|
47
57
|
assert.equal(row.task_id, task.id);
|
|
48
58
|
assert.equal(row.path, "/p2/sessions/sess-2.jsonl");
|
|
49
59
|
assert.equal(row.project_key, "p2");
|
|
50
60
|
});
|
|
51
61
|
|
|
52
|
-
test("ensureSessionMetaRow 后 setSessionTitle 只补标题、不破坏索引列", () => {
|
|
62
|
+
test("ensureSessionMetaRow 后 setSessionTitle 只补标题、不破坏索引列", async () => {
|
|
53
63
|
const db = freshDb();
|
|
54
64
|
ensureSessionMetaRow("sess-3", {
|
|
55
65
|
path: "/p3/sessions/sess-3.jsonl",
|
|
56
66
|
cwd: "/p3",
|
|
57
67
|
projectKey: "p3",
|
|
58
68
|
});
|
|
59
|
-
setSessionTitle("sess-3", "新标题");
|
|
69
|
+
await setSessionTitle("sess-3", "新标题");
|
|
60
70
|
const row = db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get("sess-3");
|
|
61
71
|
assert.equal(row.title, "新标题");
|
|
62
72
|
assert.equal(row.path, "/p3/sessions/sess-3.jsonl");
|
|
63
73
|
assert.equal(row.project_key, "p3");
|
|
64
74
|
});
|
|
65
75
|
|
|
76
|
+
test("ensureSessionMetaRow 写全 13 列(last_reply 显式 NULL,不依赖建表默认值)", () => {
|
|
77
|
+
const db = freshDb();
|
|
78
|
+
ensureSessionMetaRow("sess-13", { path: "/p13/x.jsonl", cwd: "/p13", projectKey: "p13" });
|
|
79
|
+
const columns = db.prepare("PRAGMA table_info(session_meta)").all();
|
|
80
|
+
assert.equal(columns.length, 13, "session_meta 契约是 13 列(v12)");
|
|
81
|
+
const row = db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get("sess-13");
|
|
82
|
+
assert.equal(Object.keys(row).length, 13, "行值对象覆盖全部列");
|
|
83
|
+
assert.equal(row.last_reply, null);
|
|
84
|
+
});
|
|
85
|
+
|
|
66
86
|
test("unassignSession 删行后 ensureSessionMetaRow 可重建(幂等)", () => {
|
|
67
87
|
const db = freshDb();
|
|
68
88
|
ensureSessionMetaRow("sess-4", { path: "/p4/x.jsonl", cwd: "/p4", projectKey: "p4" });
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test, { after } from "node:test";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { createJiti } from "jiti";
|
|
8
|
+
|
|
9
|
+
// getAgentDir() 每次调用都读环境变量 → 指向临时树,测试绝不碰 ~/.pi/agent。
|
|
10
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-web-partial-row-"));
|
|
11
|
+
const sessionsDir = join(agentDir, "sessions");
|
|
12
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
13
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
14
|
+
after(() => rmSync(agentDir, { recursive: true, force: true }));
|
|
15
|
+
|
|
16
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
|
|
17
|
+
const { getDb, setDbForTesting } = await jiti.import("./sqlite-db.ts");
|
|
18
|
+
const store = await jiti.import("./task-store.ts");
|
|
19
|
+
|
|
20
|
+
/** 造一个真实会话文件:文件名带 id(按名可解析)、header.id 与之一致(header 权威)。
|
|
21
|
+
* parentPath 非空时写 header.parentSession(父链只在磁盘上时用)。 */
|
|
22
|
+
function writeSession(project, id, parentPath) {
|
|
23
|
+
const dir = join(sessionsDir, project);
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
const file = join(dir, `2026-09-18T00-00-00_${id}.jsonl`);
|
|
26
|
+
writeFileSync(
|
|
27
|
+
file,
|
|
28
|
+
`${JSON.stringify({ type: "session", id, timestamp: "2026-09-18T00:00:00.000Z", cwd: join("/w", project), ...(parentPath ? { parentSession: parentPath } : {}) })}\n`,
|
|
29
|
+
);
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function freshDb() {
|
|
34
|
+
const db = new DatabaseSync(":memory:");
|
|
35
|
+
setDbForTesting(db);
|
|
36
|
+
return db;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rowFor(sessionId) {
|
|
40
|
+
return getDb().prepare("SELECT * FROM session_meta WHERE session_id = ?").get(sessionId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 全列断言:缺 path/project_key/created 的局部行会在聊天区被 project_key 过滤掉、
|
|
44
|
+
* 在任务区显示 1970 —— 正是本任务要根除的形态。 */
|
|
45
|
+
function assertFullRow(sessionId, filePath) {
|
|
46
|
+
const row = rowFor(sessionId);
|
|
47
|
+
assert.ok(row, `${sessionId} 必须建行`);
|
|
48
|
+
assert.equal(row.path, filePath);
|
|
49
|
+
assert.equal(row.cwd, "/w/alpha");
|
|
50
|
+
assert.ok(row.project_key, "project_key 不能为空(否则被聊天区 project_key 过滤踢掉)");
|
|
51
|
+
assert.ok(row.created > 0, "created 必须来自文件,不能是 0(1970)");
|
|
52
|
+
assert.ok(row.modified > 0, "modified 必须来自文件");
|
|
53
|
+
return row;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 断言 miss 场景的可见性时静音 console.error(测试输出保持干净),仍校验调用次数。 */
|
|
57
|
+
function captureConsoleError(t) {
|
|
58
|
+
const original = console.error;
|
|
59
|
+
const calls = [];
|
|
60
|
+
console.error = (...args) => {
|
|
61
|
+
calls.push(args.map(String).join(" "));
|
|
62
|
+
};
|
|
63
|
+
t.after(() => {
|
|
64
|
+
console.error = original;
|
|
65
|
+
});
|
|
66
|
+
return calls;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("setSessionTitle 对无行会话建全列行(不再是只有 title 的局部行)", async () => {
|
|
70
|
+
freshDb();
|
|
71
|
+
const file = writeSession("alpha", "sess-title");
|
|
72
|
+
|
|
73
|
+
await store.setSessionTitle("sess-title", "新名字");
|
|
74
|
+
|
|
75
|
+
const row = assertFullRow("sess-title", file);
|
|
76
|
+
assert.equal(row.title, "新名字");
|
|
77
|
+
assert.equal(row.task_id, null);
|
|
78
|
+
assert.equal(row.pinned, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("setSessionPinned 对无行会话建全列行(不再是只有 pinned 的局部行)", async () => {
|
|
82
|
+
freshDb();
|
|
83
|
+
const file = writeSession("alpha", "sess-pin");
|
|
84
|
+
|
|
85
|
+
await store.setSessionPinned("sess-pin", true);
|
|
86
|
+
|
|
87
|
+
const row = assertFullRow("sess-pin", file);
|
|
88
|
+
assert.equal(row.pinned, 1);
|
|
89
|
+
assert.equal(row.task_id, null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("assignSessionToTask 对无行会话先建全列行再写归属", async () => {
|
|
93
|
+
freshDb();
|
|
94
|
+
const file = writeSession("alpha", "sess-assign");
|
|
95
|
+
const task = store.createTask("alpha-proj", "任务");
|
|
96
|
+
|
|
97
|
+
assert.equal(await store.assignSessionToTask("sess-assign", task.id), true);
|
|
98
|
+
|
|
99
|
+
const row = assertFullRow("sess-assign", file);
|
|
100
|
+
assert.equal(row.task_id, task.id, "归属与本轮建行同一次写入完成");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("assignSessionSubtreeToTask 对无行会话同样先建全列行(子树归属不静默写 0 行)", async () => {
|
|
104
|
+
freshDb();
|
|
105
|
+
const file = writeSession("alpha", "sess-subtree");
|
|
106
|
+
const task = store.createTask("alpha-proj", "任务");
|
|
107
|
+
|
|
108
|
+
assert.equal(await store.assignSessionSubtreeToTask("sess-subtree", task.id), true);
|
|
109
|
+
|
|
110
|
+
const row = assertFullRow("sess-subtree", file);
|
|
111
|
+
assert.equal(row.task_id, task.id);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("已有行时不看文件:库里行在 → 改名/置顶照常写入(不因文件丢失而丢写)", async () => {
|
|
115
|
+
const db = freshDb();
|
|
116
|
+
db.prepare(
|
|
117
|
+
"INSERT INTO session_meta (session_id, task_id, updated, pinned, path, cwd, project_key, created, modified) VALUES (?, NULL, ?, 0, ?, ?, ?, ?, ?)",
|
|
118
|
+
).run("sess-row-only", Date.now(), "/gone/sess-row-only.jsonl", "/w/alpha", "/w/alpha", 1, 2);
|
|
119
|
+
|
|
120
|
+
await store.setSessionTitle("sess-row-only", "改名");
|
|
121
|
+
await store.setSessionPinned("sess-row-only", true);
|
|
122
|
+
|
|
123
|
+
const row = rowFor("sess-row-only");
|
|
124
|
+
assert.equal(row.title, "改名");
|
|
125
|
+
assert.equal(row.pinned, 1);
|
|
126
|
+
assert.equal(row.path, "/gone/sess-row-only.jsonl", "既有索引列不被覆盖");
|
|
127
|
+
assert.equal(row.project_key, "/w/alpha");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("库里没行、磁盘也没文件 → 不写缺列局部行,报错可见", async (t) => {
|
|
131
|
+
const db = freshDb();
|
|
132
|
+
const logs = captureConsoleError(t);
|
|
133
|
+
|
|
134
|
+
await store.setSessionTitle("sess-ghost", "幽灵改名");
|
|
135
|
+
await store.setSessionPinned("sess-ghost", true);
|
|
136
|
+
|
|
137
|
+
assert.equal(db.prepare("SELECT COUNT(*) c FROM session_meta").get().c, 0, "绝不退回插局部行");
|
|
138
|
+
assert.equal(logs.length, 2, "两次写库各报一次错(失败可见,不静默)");
|
|
139
|
+
for (const line of logs) assert.match(line, /sess-ghost/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── 补行时的 parent_id(归属守卫的可见性)────────────────────────────────────
|
|
143
|
+
// 守卫(hasForeignTaskAncestor)只看库内 parent_id:库里还没行的会话,父链只在磁盘
|
|
144
|
+
// header 里——补行时不写父,跨任务祖先对守卫就是盲区(无行会话会被当成顶层行放行)。
|
|
145
|
+
|
|
146
|
+
test("无行会话补行时写 parent_id:磁盘父属别的任务 → 归属被拒绝(与「有行」路径同结论)", async (t) => {
|
|
147
|
+
const db = freshDb();
|
|
148
|
+
const logs = captureConsoleError(t);
|
|
149
|
+
const parentFile = writeSession("alpha", "guard-parent");
|
|
150
|
+
const other = store.createTask("alpha-proj", "别的任务");
|
|
151
|
+
db.prepare(
|
|
152
|
+
"INSERT INTO session_meta (session_id, task_id, updated, pinned, path, cwd, project_key, parent_id, created, modified) VALUES ('guard-parent', ?, 1, 0, ?, '/w/alpha', '/w/alpha', NULL, 1, 1)",
|
|
153
|
+
).run(other.id, parentFile);
|
|
154
|
+
// 子会话:磁盘上有父指针,库里**没有行**(正是守卫此前看不见的那一类)。
|
|
155
|
+
const childFile = writeSession("alpha", "guard-child", parentFile);
|
|
156
|
+
const target = store.createTask("alpha-proj", "目标任务");
|
|
157
|
+
|
|
158
|
+
const assigned = await store.assignSessionSubtreeToTask("guard-child", target.id);
|
|
159
|
+
|
|
160
|
+
assert.equal(assigned, false, "祖先在别的任务 → 拒绝(返回 false 而不是静默归属)");
|
|
161
|
+
assert.equal(store.taskForSession("guard-child"), null, "拒绝 → 不写归属");
|
|
162
|
+
assert.equal(store.taskForSession("guard-parent"), other.id, "祖先归属不动");
|
|
163
|
+
assert.ok(
|
|
164
|
+
logs.some((line) => /祖先属于其它任务/.test(line)),
|
|
165
|
+
"拒绝可见(console.error)",
|
|
166
|
+
);
|
|
167
|
+
const row = rowFor("guard-child");
|
|
168
|
+
assert.ok(row, "补行照常完成(列齐全)");
|
|
169
|
+
assert.equal(row.path, childFile);
|
|
170
|
+
assert.equal(row.parent_id, "guard-parent", "补行时由磁盘 header.parentSession 反查写入父 id");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("无行会话补行写 parent_id 的正向面:磁盘父属同一任务 → 归属成功且父链保持", async () => {
|
|
174
|
+
const db = freshDb();
|
|
175
|
+
const parentFile = writeSession("alpha", "same-parent");
|
|
176
|
+
const task = store.createTask("alpha-proj", "同一个任务");
|
|
177
|
+
db.prepare(
|
|
178
|
+
"INSERT INTO session_meta (session_id, task_id, updated, pinned, path, cwd, project_key, parent_id, created, modified) VALUES ('same-parent', ?, 1, 0, ?, '/w/alpha', '/w/alpha', NULL, 1, 1)",
|
|
179
|
+
).run(task.id, parentFile);
|
|
180
|
+
writeSession("alpha", "same-child", parentFile);
|
|
181
|
+
|
|
182
|
+
assert.equal(await store.assignSessionSubtreeToTask("same-child", task.id), true);
|
|
183
|
+
const row = rowFor("same-child");
|
|
184
|
+
assert.equal(row.parent_id, "same-parent");
|
|
185
|
+
assert.equal(row.task_id, task.id, "同任务祖先不构成冲突");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("无行会话的父反查不到(父行未建/父文件已消失)→ parent_id 留空,不被当成冲突", async () => {
|
|
189
|
+
const db = freshDb();
|
|
190
|
+
writeSession("alpha", "orphan-child", join(sessionsDir, "alpha", "2026-09-18T00-00-00_gone.jsonl"));
|
|
191
|
+
const task = store.createTask("alpha-proj", "任务");
|
|
192
|
+
|
|
193
|
+
assert.equal(await store.assignSessionSubtreeToTask("orphan-child", task.id), true);
|
|
194
|
+
const row = rowFor("orphan-child");
|
|
195
|
+
assert.equal(row.parent_id, null, "反查不到 → undefined(不写悬空指针)");
|
|
196
|
+
assert.equal(row.task_id, task.id);
|
|
197
|
+
assert.equal(db.prepare("SELECT COUNT(*) c FROM session_meta").get().c, 1);
|
|
198
|
+
});
|