pi-skill-evolve 0.1.4 → 0.1.5

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.
Files changed (31) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  3. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  4. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  5. package/extensions/skill-evolve/lib/report.ts +4 -1
  6. package/extensions/skill-evolve/lib/runs.ts +35 -2
  7. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  8. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  9. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  10. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  11. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  12. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  13. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  14. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  15. package/extensions/skill-evolve/lib/web/render-rows.ts +37 -6
  16. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  17. package/extensions/skill-evolve/lib/web/render-session.ts +67 -0
  18. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  19. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  20. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  21. package/extensions/skill-evolve/lib/web/server.ts +13 -1
  22. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  23. package/extensions/skill-evolve/lib/web/session-view.ts +260 -0
  24. package/extensions/skill-evolve/lib/web/style.ts +17 -0
  25. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  26. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  27. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  28. package/extensions/skill-evolve/test/session-page.test.ts +194 -0
  29. package/extensions/skill-evolve/test/session-view.test.ts +192 -0
  30. package/extensions/skill-evolve/test/web-session-column.test.ts +141 -0
  31. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * 同会话聚合检测:同一个会话已经有在途复盘时,本轮**并入**它,不再另开一条 run。
3
+ *
4
+ * 为什么需要(用户 2026-11-08:「如果是同一会话产生的多个, 并且他还没开始跑复盘, 会导致一会话多次跑复盘」):
5
+ * runId 是 `会话哈希8-时间戳`,每轮结算都算出一个新的;全局单飞闸只保证「同时只有一个子进程」,
6
+ * 挡不住「队列里排着同一个会话的好几条」。真实现场(2026-11-08 的 vkk_client_flutter 工作区):
7
+ * 同一个 sessionId 下堆了 1 条 analyzed + 7 条 stale,同一段会话被反反复复复盘。
8
+ *
9
+ * 三条边界,缺一条就会误并:
10
+ * ① 只并 in-flight(status=dispatched)的 run;analyzed / proposed / reviewed 表示复盘确实跑完了,
11
+ * 那是新的一轮对话,允许另开;
12
+ * ② 只并**子进程还活着**的 run:pid 已死说明它已经是尸体,交给 lib/stale.ts 标 stale,本轮照旧另开;
13
+ * ③ 01-findings.json 已落盘的一律不并 —— 状态可能还没被回写,判据与 runs.ts 的 listRuns 同一口径。
14
+ *
15
+ * 为什么这里用同步 fs:两个调用点(异步派发 / 退出前同步补发)必须给出**同一个**判定,
16
+ * 拆成异步 + 同步两套实现迟早分叉;扫描量是 runs/ 下 ≤100 个 run.json,同步读的代价远小于分叉的代价。
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { isPidAlive } from "./lock.ts";
21
+ import { parseCoverage, patchRunRecordSync, type RunCoverage } from "./runs.ts";
22
+ import type { TelemetryWriter } from "./telemetry.ts";
23
+
24
+ /** 只有这个状态算「复盘还在跑」:analyzed 起都是跑完了 */
25
+ export const IN_FLIGHT_STATUS = "dispatched";
26
+ /** 扫描上限:与面板同一个量级,够了;runs/ 里可能有几百个历史目录 */
27
+ const MAX_SCAN = 100;
28
+
29
+ export interface AggregateRun {
30
+ runId: string;
31
+ runDir: string;
32
+ sessionId: string;
33
+ status: string;
34
+ pid: number | null;
35
+ dispatchedAt: number;
36
+ /** 01-findings.json 已落盘 = 复盘其实已经跑完(run.json 的状态可能还没被回写) */
37
+ hasFindings: boolean;
38
+ /** run.json 里已有的覆盖轮数(并入时要在它上面累加) */
39
+ coverage: RunCoverage | null;
40
+ }
41
+
42
+ /** 扫 runs/ 下最近 MAX_SCAN 条;run.json 缺失或坏掉一律跳过(坏记录不能伪造出一条在途 run) */
43
+ export function readAggregateRuns(evolutionDir: string): AggregateRun[] {
44
+ const runsDir = path.join(evolutionDir, "runs");
45
+ let names: string[];
46
+ try {
47
+ names = fs.readdirSync(runsDir);
48
+ } catch {
49
+ return [];
50
+ }
51
+ const runs: AggregateRun[] = [];
52
+ for (const name of names) {
53
+ const runDir = path.join(runsDir, name);
54
+ let raw: Record<string, unknown>;
55
+ try {
56
+ raw = JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as Record<string, unknown>;
57
+ } catch {
58
+ continue;
59
+ }
60
+ runs.push({
61
+ runId: typeof raw.runId === "string" ? raw.runId : name,
62
+ runDir,
63
+ sessionId: typeof raw.sessionId === "string" ? raw.sessionId : "",
64
+ status: typeof raw.status === "string" ? raw.status : "unknown",
65
+ pid: typeof raw.pid === "number" ? raw.pid : null,
66
+ dispatchedAt: typeof raw.dispatchedAt === "number" ? raw.dispatchedAt : 0,
67
+ hasFindings: fs.existsSync(path.join(runDir, "01-findings.json")),
68
+ coverage: parseCoverage(raw.coverage),
69
+ });
70
+ }
71
+ return runs.sort((a, b) => b.dispatchedAt - a.dispatchedAt).slice(0, MAX_SCAN);
72
+ }
73
+
74
+ /** 纯函数:找「同一会话、复盘还在跑、子进程活着」的那条 run;多条命中时取最近派发的 */
75
+ export function findSameSessionRun(
76
+ runs: readonly AggregateRun[],
77
+ sessionId: string,
78
+ isAlive: (pid: number) => boolean,
79
+ now: number,
80
+ ): AggregateRun | null {
81
+ let found: AggregateRun | null = null;
82
+ for (const run of runs) {
83
+ if (run.sessionId !== sessionId) continue;
84
+ if (run.status !== IN_FLIGHT_STATUS || run.hasFindings) continue;
85
+ if (run.pid === null || !isAlive(run.pid)) continue;
86
+ // 时钟回拨写出来的「未来」派发时刻无法判断真伪:宁可不并(另开一条至少看得见)
87
+ if (run.dispatchedAt > now) continue;
88
+ if (found === null || run.dispatchedAt > found.dispatchedAt) found = run;
89
+ }
90
+ return found;
91
+ }
92
+
93
+ export interface MergeRoundInput {
94
+ evolutionDir: string;
95
+ sessionId: string;
96
+ /** 本次要记的轮次总数(本轮 + 之前被去抖挡下的轮数) */
97
+ rounds: number;
98
+ now: number;
99
+ /** pid 存活判定(默认 kill(pid,0);单测注入,测试里不起真进程) */
100
+ isAlive?: (pid: number) => boolean;
101
+ telemetry?: TelemetryWriter;
102
+ log?: (msg: string) => void;
103
+ }
104
+
105
+ export interface MergeOutcome {
106
+ runId: string;
107
+ /** 并入后的累计轮数 */
108
+ rounds: number;
109
+ }
110
+
111
+ /**
112
+ * 读盘 → 判定 → 打补丁。没命中返回 null,调用方照旧派生新 run。
113
+ * 任何异常都吞掉并按「未命中」处理:聚合是省一次复盘,不能反过来把派发流程搞挂。
114
+ */
115
+ export function mergeRoundIntoRun(input: MergeRoundInput): MergeOutcome | null {
116
+ try {
117
+ const target = findSameSessionRun(
118
+ readAggregateRuns(input.evolutionDir),
119
+ input.sessionId,
120
+ input.isAlive ?? isPidAlive,
121
+ input.now,
122
+ );
123
+ if (target === null) return null;
124
+ const coverage = mergedCoverage(target, input);
125
+ // run.json 读不到了:按未命中处理,走老路另开一条(宁可多一条看得见的 run,也不静默吞掉这一轮)
126
+ if (!patchRunRecordSync(target.runDir, { coverage })) return null;
127
+ input.telemetry?.append({ kind: "merged", sessionId: input.sessionId, runId: target.runId, rounds: coverage.rounds });
128
+ input.log?.(
129
+ `[skill-evolve] 同会话已有在途复盘 ${target.runId},本轮并入(共 ${coverage.rounds} 轮),不再派生`,
130
+ );
131
+ return { runId: target.runId, rounds: coverage.rounds };
132
+ } catch (error) {
133
+ input.log?.(`[skill-evolve] 同会话聚合检测失败(按未命中处理):${String(error)}`);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 并入后的 coverage:在已有轮数上**累加**本次并入的轮数。
140
+ *
141
+ * `rounds` 必须做加法而不是取 max:那条在途 run 派发时已经覆盖了 1 轮(run.json 里可能还没有
142
+ * coverage 字段,那就按 1 算),本次再并入 pending 里的 N 轮,总数是 1 + N。
143
+ * `since` 保留原起点(区间起点不变,只是末尾又长了一截)。
144
+ */
145
+ function mergedCoverage(target: AggregateRun, input: MergeRoundInput): RunCoverage {
146
+ const base = target.coverage;
147
+ return {
148
+ rounds: (base?.rounds ?? 1) + input.rounds,
149
+ since: base?.since ?? target.dispatchedAt,
150
+ lastAt: input.now,
151
+ merged: (base?.merged ?? 0) + 1,
152
+ };
153
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 派发层的类型:输入 / 依赖 / 结果。
3
+ *
4
+ * 为什么单独一个文件:dispatch.ts 已经贴着 300 行闸门,而这几块是**纯声明**(没有一行逻辑),
5
+ * 搬出来不改变任何行为;dispatch.ts 仍然把它们原样再导出,调用方按老路径取类型不受影响。
6
+ */
7
+ import type { Attribution } from "./attribution.ts";
8
+ import type { EvolutionConfig } from "./config.ts";
9
+ import type { RunRecord } from "./runs.ts";
10
+ import type { SpawnLike } from "./spawn-plan.ts";
11
+ import type { SupervisorSpawn } from "./review-spawn.ts";
12
+ import type { MutationQueue, TelemetryWriter } from "./telemetry.ts";
13
+
14
+ export interface DispatchInput {
15
+ sessionId: string;
16
+ skills: string[];
17
+ sessionFile?: string;
18
+ /** 已经机械归因过的技能(A~E):写进 prompt,复盘 agent 不必重判 */
19
+ attributions?: Attribution[];
20
+ }
21
+
22
+ export interface DispatchDeps {
23
+ evolutionDir: string;
24
+ cwd: string;
25
+ config: EvolutionConfig;
26
+ telemetry: TelemetryWriter;
27
+ queue: MutationQueue;
28
+ promptTemplatePath: string;
29
+ log?: (msg: string) => void;
30
+ now?: () => number;
31
+ env?: NodeJS.ProcessEnv;
32
+ spawnFn?: SpawnLike;
33
+ /** 评审模型("inherit" 或 provider/model):每次派生现取,配置改动立即生效 */
34
+ reviewModel?: () => string;
35
+ /** 必红自检没过 → 整条流水线停用(设计稿 12.4) */
36
+ selftestFailed?: () => boolean;
37
+ /** 评审督导的 spawn(默认 node spawn;单测注入假实现) */
38
+ supervisorSpawnFn?: SupervisorSpawn;
39
+ /** pid 存活判定(全局单飞闸用;单测注入,测试里不起真子进程) */
40
+ alive?: (pid: number) => boolean;
41
+ }
42
+
43
+ export interface DispatchedRun {
44
+ runId: string;
45
+ runDir: string;
46
+ pid: number | null;
47
+ }
48
+
49
+ /** 派发结果:调用方(命令 / 事件)要据此出文案,所以不能再只用 null 表示「没派」 */
50
+ export type DispatchOutcome =
51
+ | { status: "dispatched"; run: DispatchedRun }
52
+ | {
53
+ status: "skipped";
54
+ why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy";
55
+ ownerPid: number | null;
56
+ }
57
+ | { status: "superseded"; rounds: number }
58
+ /** 同一会话已有在途复盘:这一轮并进去了,既没建新 run 也没起进程(见 lib/aggregate.ts) */
59
+ | { status: "merged"; runId: string; rounds: number };
60
+
61
+ export interface DispatchOptions {
62
+ /** 手动 `/skill-evolve run`:跳过去抖与单会话上限,但**仍必须守全局锁** */
63
+ bypassThrottle?: boolean;
64
+ trigger?: RunRecord["trigger"];
65
+ }
@@ -10,67 +10,23 @@
10
10
  */
11
11
  import { spawn } from "node:child_process";
12
12
  import * as path from "node:path";
13
- import { mergeAttributions, type Attribution } from "./attribution.ts";
14
- import type { EvolutionConfig } from "./config.ts";
13
+ import { mergeRoundIntoRun } from "./aggregate.ts";
14
+ import { mergeAttributions } from "./attribution.ts";
15
15
  import { acquireLock, releaseLock, writeOwnerSync } from "./lock.ts";
16
16
  import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
17
- import { writeRunRecord, type RunRecord } from "./runs.ts";
17
+ import { writeRunRecord } from "./runs.ts";
18
18
  import { writeSessionIndex } from "./session-index.ts";
19
- import { CHILD_LOG, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf, type SpawnLike } from "./spawn-plan.ts";
19
+ import { CHILD_LOG, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf } from "./spawn-plan.ts";
20
20
  import { mergePendingRounds } from "./pending.ts";
21
21
  import { watchRunChild } from "./run-watch.ts";
22
- import { spawnReviewSupervisor, type SupervisorSpawn } from "./review-spawn.ts";
22
+ import { spawnReviewSupervisor } from "./review-spawn.ts";
23
23
  import { guardBusy } from "./single-flight.ts";
24
24
  import { drainPending } from "./drain-pending.ts";
25
25
  import type { PendingRounds } from "./shutdown-dispatch.ts";
26
- import type { MutationQueue, TelemetryWriter } from "./telemetry.ts";
26
+ import type { DispatchDeps, DispatchInput, DispatchOptions, DispatchOutcome } from "./dispatch-types.ts";
27
27
 
28
- export interface DispatchInput {
29
- sessionId: string;
30
- skills: string[];
31
- sessionFile?: string;
32
- /** 已经机械归因过的技能(A~E):写进 prompt,复盘 agent 不必重判 */
33
- attributions?: Attribution[];
34
- }
35
-
36
- export interface DispatchDeps {
37
- evolutionDir: string;
38
- cwd: string;
39
- config: EvolutionConfig;
40
- telemetry: TelemetryWriter;
41
- queue: MutationQueue;
42
- promptTemplatePath: string;
43
- log?: (msg: string) => void;
44
- now?: () => number;
45
- env?: NodeJS.ProcessEnv;
46
- spawnFn?: SpawnLike;
47
- /** 评审模型("inherit" 或 provider/model):每次派生现取,配置改动立即生效 */
48
- reviewModel?: () => string;
49
- /** 必红自检没过 → 整条流水线停用(设计稿 12.4) */
50
- selftestFailed?: () => boolean;
51
- /** 评审督导的 spawn(默认 node spawn;单测注入假实现) */
52
- supervisorSpawnFn?: SupervisorSpawn;
53
- /** pid 存活判定(全局单飞闸用;单测注入,测试里不起真子进程) */
54
- alive?: (pid: number) => boolean;
55
- }
56
-
57
- export interface DispatchedRun {
58
- runId: string;
59
- runDir: string;
60
- pid: number | null;
61
- }
62
-
63
- /** 派发结果:调用方(命令 / 事件)要据此出文案,所以不能再只用 null 表示「没派」 */
64
- export type DispatchOutcome =
65
- | { status: "dispatched"; run: DispatchedRun }
66
- | { status: "skipped"; why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy"; ownerPid: number | null }
67
- | { status: "superseded"; rounds: number };
68
-
69
- export interface DispatchOptions {
70
- /** 手动 `/skill-evolve run`:跳过去抖与单会话上限,但**仍必须守全局锁** */
71
- bypassThrottle?: boolean;
72
- trigger?: RunRecord["trigger"];
73
- }
28
+ // 类型都搬到了 ./dispatch-types.ts(本文件贴着 300 行闸门),这里原样再导出,调用方路径不变
29
+ export type { DispatchInput, DispatchDeps, DispatchedRun, DispatchOutcome, DispatchOptions } from "./dispatch-types.ts";
74
30
 
75
31
  /** 去抖:窗口内又结束一轮,只更新 pending,不再叠加进程 */
76
32
  export function shouldDebounce(lastDispatchAt: number | undefined, now: number, idleMs: number): boolean {
@@ -106,6 +62,26 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
106
62
  return merged;
107
63
  };
108
64
 
65
+ /**
66
+ * 并入在途复盘的这一轮:不建 run、不抢锁、不起进程,只把轮数累加进那条 run 的 coverage。
67
+ * 手动 `/skill-evolve run` 也走这道检查 —— 「立刻复盘」不该变成「同一会话并行两条」。
68
+ */
69
+ const tryMerge = (input: DispatchInput, at: number): DispatchOutcome | null => {
70
+ const result = mergeRoundIntoRun({
71
+ evolutionDir: deps.evolutionDir,
72
+ sessionId: input.sessionId,
73
+ // 本轮 + 之前被去抖挡下、还没派出去的轮次
74
+ rounds: (pending.get(input.sessionId)?.rounds ?? 0) + 1,
75
+ now: at,
76
+ telemetry: deps.telemetry,
77
+ log,
78
+ ...(deps.alive ? { isAlive: deps.alive } : {}),
79
+ });
80
+ if (result === null) return null;
81
+ pending.delete(input.sessionId);
82
+ return { status: "merged", runId: result.runId, rounds: result.rounds };
83
+ };
84
+
109
85
  const run = async (input: DispatchInput, options: DispatchOptions = {}): Promise<DispatchOutcome> => {
110
86
  const at = now();
111
87
  const bypass = options.bypassThrottle === true;
@@ -144,6 +120,10 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
144
120
  }
145
121
  }
146
122
 
123
+ // 同会话聚合:已有在途复盘就把这一轮并进去(放单飞闸之前 —— 单飞闸只会回一句 busy 然后丢掉这一轮)
124
+ const merged = tryMerge(input, at);
125
+ if (merged) return merged;
126
+
147
127
  // 全局单飞(跨会话也成立):已有复盘或督导在跑这一轮就不叠进程;放去抖 / 上限之后,那两条是「本轮不派」的既定语义
148
128
  const occupant = guardBusy({ evolutionDir: deps.evolutionDir, sessionId: input.sessionId, telemetry: deps.telemetry, trigger, alive: deps.alive, log });
149
129
  if (occupant) return { status: "skipped", why: "busy", ownerPid: occupant.pid };
@@ -218,6 +218,7 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
218
218
  })
219
219
  .then((outcome) => {
220
220
  if (outcome.status === "skipped") log(`[skill-evolve] 本轮未派生:${outcome.why}`);
221
+ // merged:并入在途 run 的日志由派发器打(那里能看到轮数),这里不重复
221
222
  else if (outcome.status === "dispatched") observer.noteDispatched(outcome.run.runId);
222
223
  })
223
224
  .catch((error: unknown) => recordHandlerError("dispatch", error));
@@ -161,7 +161,7 @@ export function renderReport(input: ReportInput): string {
161
161
  ].join("\n");
162
162
  }
163
163
 
164
- /** 手动 `/skill-evolve run` 的回执:把三种 outcome 翻译成一句人话(CLI 回显用) */
164
+ /** 手动 `/skill-evolve run` 的回执:把四种 outcome 翻译成一句人话(CLI 回显用) */
165
165
  export function renderManualDispatch(outcome: DispatchOutcome): string {
166
166
  if (outcome.status === "dispatched") {
167
167
  const { runId, pid, runDir } = outcome.run;
@@ -176,5 +176,8 @@ export function renderManualDispatch(outcome: DispatchOutcome): string {
176
176
  }
177
177
  return `本次未派发:${outcome.why}`;
178
178
  }
179
+ if (outcome.status === "merged") {
180
+ return `同一会话已有在途复盘 ${outcome.runId}:本轮并进去了(共 ${outcome.rounds} 轮),不再另派`;
181
+ }
179
182
  return `本轮已在待复盘区间里(累计 ${outcome.rounds} 轮),静默窗口过后会自动补一次`;
180
183
  }
@@ -37,6 +37,20 @@ export interface RunStage {
37
37
  ms: number;
38
38
  }
39
39
 
40
+ /**
41
+ * 覆盖轮数:`rounds` 是这条 run 一共复盘了几轮,`merged` 是其中有多少轮属于
42
+ * 「同一会话已有在途复盘」被并进来的(见 lib/aggregate.ts)。
43
+ */
44
+ export interface RunCoverage {
45
+ rounds: number;
46
+ /** 覆盖起点:第一次被挡下的时刻(缺省同 dispatchedAt) */
47
+ since: number;
48
+ /** 最近一次并进新轮次的时刻 */
49
+ lastAt?: number;
50
+ /** 其中有多少轮是并入在途 run 的(缺省 0) */
51
+ merged?: number;
52
+ }
53
+
40
54
  export interface RunRecord {
41
55
  runId: string;
42
56
  status: RunStatus;
@@ -54,8 +68,8 @@ export interface RunRecord {
54
68
  supervisorPid?: number | null;
55
69
  cwd: string;
56
70
  skills: string[];
57
- /** 去抖合并掉的轮次范围(缺省 = 只覆盖刚结束的 1 轮) */
58
- coverage?: { rounds: number; since: number };
71
+ /** 去抖合并 / 同会话聚合记下的轮次范围(缺省 = 只覆盖刚结束的 1 轮) */
72
+ coverage?: RunCoverage;
59
73
  stages: RunStage[];
60
74
  artifacts: Record<string, string>;
61
75
  /** M3:复盘子进程产出提案后回写({type, skill, status, added, removed, file}) */
@@ -79,6 +93,10 @@ export interface RunSummary {
79
93
  status: string;
80
94
  dispatchedAt: number | null;
81
95
  sessionId: string;
96
+ /** 会话原文的绝对路径:面板据此打开「会话详情」页(同会话聚合也要它) */
97
+ sessionFile: string | null;
98
+ /** 复盘的轮次覆盖(缺省 null = 只覆盖 1 轮,没被并过) */
99
+ coverage: RunCoverage | null;
82
100
  /** 子进程 pid:面板靠它判「还活着吗」(见 lib/stale.ts) */
83
101
  pid: number | null;
84
102
  /** 评审督导 pid:面板据此把阶段标成「评审中」 */
@@ -167,6 +185,19 @@ export async function updateRunStatus(
167
185
  return found;
168
186
  }
169
187
 
188
+ /** run.json 里的 coverage:字段缺失或类型不对一律当 null(坏数据当空,不让它污染展示) */
189
+ export function parseCoverage(value: unknown): RunCoverage | null {
190
+ if (typeof value !== "object" || value === null) return null;
191
+ const record = value as Record<string, unknown>;
192
+ if (typeof record.rounds !== "number" || typeof record.since !== "number") return null;
193
+ return {
194
+ rounds: record.rounds,
195
+ since: record.since,
196
+ ...(typeof record.lastAt === "number" ? { lastAt: record.lastAt } : {}),
197
+ ...(typeof record.merged === "number" ? { merged: record.merged } : {}),
198
+ };
199
+ }
200
+
170
201
  /** 按派生时间倒序列出最近若干个 run(run.json 坏掉的目录直接跳过) */
171
202
  export function listRuns(evolutionDir: string, limit = 10): RunSummary[] {
172
203
  const dir = path.join(evolutionDir, "runs");
@@ -195,6 +226,8 @@ export function listRuns(evolutionDir: string, limit = 10): RunSummary[] {
195
226
  status,
196
227
  dispatchedAt: typeof raw.dispatchedAt === "number" ? raw.dispatchedAt : null,
197
228
  sessionId: raw.sessionId ?? "",
229
+ sessionFile: typeof raw.sessionFile === "string" ? raw.sessionFile : null,
230
+ coverage: parseCoverage(raw.coverage),
198
231
  pid: typeof raw.pid === "number" ? raw.pid : null,
199
232
  supervisorPid: typeof raw.supervisorPid === "number" ? raw.supervisorPid : null,
200
233
  ...(proposal ? { proposal } : {}),
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
+ import { mergeRoundIntoRun } from "./aggregate.ts";
10
11
  import type { Attribution } from "./attribution.ts";
11
12
  import { acquireLockSync, releaseLockSync, writeOwnerSync } from "./lock.ts";
12
13
  import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
@@ -48,10 +49,32 @@ export interface ShutdownDeps {
48
49
  log: (msg: string) => void;
49
50
  }
50
51
 
51
- export type ShutdownResult = "dispatched" | "skipped" | "failed";
52
+ /** `merged`:同一会话已有在途复盘,这次补发的轮次并进去了(不建 run、不起进程) */
53
+ export type ShutdownResult = "dispatched" | "skipped" | "failed" | "merged";
52
54
 
53
55
  /** 同步补发一次:任何异常都在内部吞掉,绝不阻塞退出、绝不抛给调用方 */
54
56
  export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: number): ShutdownResult {
57
+ // 同会话聚合:已有在途复盘就把这几轮并进去(与异步派发同一判定,见 lib/aggregate.ts)
58
+ const merged = mergeRoundIntoRun({
59
+ evolutionDir: deps.evolutionDir,
60
+ sessionId: item.sessionId,
61
+ rounds: item.rounds,
62
+ now,
63
+ ...(deps.alive ? { isAlive: deps.alive } : {}),
64
+ log: deps.log,
65
+ });
66
+ if (merged) {
67
+ // 退出路径必须同步落盘:异步队列很可能来不及写,进程就没了
68
+ deps.telemetry.appendSync({
69
+ kind: "merged",
70
+ ts: now,
71
+ sessionId: item.sessionId,
72
+ runId: merged.runId,
73
+ rounds: merged.rounds,
74
+ });
75
+ return "merged";
76
+ }
77
+
55
78
  // 全局单飞:退出补发也不能叠进程(已有活的复盘 / 督导就放弃本次,不建目录不抢锁)
56
79
  const occupant = guardBusy({
57
80
  evolutionDir: deps.evolutionDir,
@@ -21,6 +21,8 @@ export type TelemetryKind =
21
21
  | "missed"
22
22
  | "pending"
23
23
  | "dispatched"
24
+ /** 同会话聚合:{kind:"merged", runId, sessionId, rounds} —— 这一轮并进了已有的在途 run */
25
+ | "merged"
24
26
  | "skipped"
25
27
  | "superseded"
26
28
  | "handler_error"
@@ -11,6 +11,7 @@ import { readVerify } from "../apply.ts";
11
11
  import { readProposalSummary } from "../proposal.ts";
12
12
  import type { QueueItem } from "../queue.ts";
13
13
  import { asNumber, asText, readJson } from "./json-read.ts";
14
+ import { readRunSession, type SessionRef } from "./run-session.ts";
14
15
 
15
16
  /** 已采纳列表上限:与 DONE_LIMIT 同量级 —— 网页看近期,不是审计日志 */
16
17
  export const APPLIED_LIMIT = 50;
@@ -18,6 +19,8 @@ export const APPLIED_LIMIT = 50;
18
19
  /** 一个已采纳条目:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸是否全 ok */
19
20
  export interface AppliedItem {
20
21
  runId: string;
22
+ /** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
23
+ session: SessionRef;
21
24
  /** auto(不需要人) / approval(人批) */
22
25
  level: string;
23
26
  skill: string;
@@ -50,6 +53,7 @@ export function buildApplied(items: readonly QueueItem[], evolutionDir: string):
50
53
  : [];
51
54
  rows.push({
52
55
  runId: item.runId,
56
+ session: readRunSession(runDir),
53
57
  level: item.level,
54
58
  skill: item.skill.length > 0 ? item.skill : (proposal?.skill ?? ""),
55
59
  type: proposal?.type ?? asText(item.proposalType, "—"),
@@ -11,9 +11,12 @@ import * as path from "node:path";
11
11
  import type { QueueItem } from "../queue.ts";
12
12
  import { readProposalSummary } from "../proposal.ts";
13
13
  import { asNumber, asText, readJson } from "./json-read.ts";
14
+ import { readRunSession, type SessionRef } from "./run-session.ts";
14
15
 
15
16
  export interface ApprovalItem {
16
17
  runId: string;
18
+ /** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
19
+ session: SessionRef;
17
20
  skill: string;
18
21
  level: string;
19
22
  /** 相对仓库根的落盘目标(队列里就存着,不重算) */
@@ -46,6 +49,7 @@ export function buildApprovals(evolutionDir: string, items: readonly QueueItem[]
46
49
  const summary = readProposalSummary(runDir);
47
50
  return {
48
51
  runId: item.runId,
52
+ session: readRunSession(runDir),
49
53
  skill: item.skill,
50
54
  level: item.level,
51
55
  targets: item.targets,