pi-skill-evolve 0.1.12 → 0.1.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.13] - 2026-11-09
4
+
5
+ 同一个工作区开两个 pi(两个窗口 / 一个窗口执行中另开一个)时的两条竞态。
6
+
7
+ - 修复:两个实例的 `session_start` 都会重挂督导,单飞闸只认「已经写进 `run.json` 的活 pid」,两边 spawn 之间的空档会各起一个督导(双花 token、双写 `03-review.json`)。现在重挂前先抢 `locks/supervisor`,抢到后再复核一次 `supervisorPid`,后到者直接写 `respawn-blocked`「另一个 pi 实例正在重挂督导」
8
+ - 修复:两个实例同时扫队列采纳会互相覆盖 `queue.json`(读-改-写),同一份 diff 甚至可能被各应用一次。`session_start` / `settle` 的「扫队列 → 应用」整段现在抢 `locks/apply`,抢不到就跳过,下一次会话起点再来
9
+ - 新增:`lockDir(evolutionDir, name)` 支持多把锁(`dispatch` / `supervisor` / `apply`,缺省仍是 `dispatch`,老行为不变);`withLock()` 跨进程互斥执行,异常也保证释放
10
+ - 测试:685 → 688(新增「两个 pi 同时恢复只起一个督导」「两把锁互不干扰」「withLock 忙时让位 + 抛错也释放」);另用两个真实 node 进程并发跑恢复验证:`spawn` 计数 1,一个 `supervisor-respawned`、一个 `respawn-blocked`
11
+
3
12
  ## [0.1.12] - 2026-11-09
4
13
 
5
14
  按 npm 包安装时「评审 → 采纳」整条链原本一次都跑不起来,这个版本修掉它,并把每轮复盘画成五段流水线。
package/README.md CHANGED
@@ -48,6 +48,15 @@ pi install git:github.com/suveng/pi-skill-evolve
48
48
  | 评审 | `03-review.json` | 提案在、督导不在 → 下次会话起点自动重挂 |
49
49
  | 采纳 | `04-decision.json` | 自动采纳关着 → 面板待审批页批准 |
50
50
 
51
+ ### 一个工作区开两个 pi
52
+
53
+ 同时开两个 pi 窗口(或一个在跑、另开一个)时:
54
+
55
+ - **复盘派发**:`locks/dispatch` 原子锁,任意时刻只有一个 run 在跑,另一个只写一条 `skipped(busy)`
56
+ - **重挂督导**:`locks/supervisor` + 抢到锁后复核 `supervisorPid`,只有一边起督导,另一边记「另一个 pi 实例正在重挂督导」
57
+ - **扫队列 / 采纳**:`locks/apply` 独占,「读-改-写 `queue.json`」与应用 diff 不会被两边同时做;抢不到的那一边跳过,下次 `settle` / 会话起点再来
58
+ - **Web 面板**:`locks/web.json` 记持有者,第二个实例不抢端口,只在日志里说「由 pid N 持有」;`/skill-evolve status` 在任何一边都能看到面板地址
59
+
51
60
  ### 重启恢复
52
61
 
53
62
  `/reload` 或重启会丢掉内存里的收尸逻辑(`watchRunChild` 的回调),所以 `session_start` 会跑一次恢复:补 `analyzed` 状态、重挂被带走的督导、其余不动。**永不重跑复盘**(花 token 且会覆盖已有产物),有动作时用一行通知说明补了什么。
@@ -14,6 +14,7 @@ import { classifyChange } from "./classify-action.ts";
14
14
  import { captureGate0, saveGate0, type GitRunner } from "./gate0.ts";
15
15
  import { buildPrunePlan, renderPrunePlan } from "./prune-plan.ts";
16
16
  import { enqueue, findItem, markDone, readQueue, takePending, type QueueItem, type QueueStatus } from "./queue.ts";
17
+ import { withLock } from "./lock.ts";
17
18
  import { logLine } from "./logfile.ts";
18
19
  import { updateRunStatus } from "./runs.ts";
19
20
  import type { MutationQueue } from "./telemetry.ts";
@@ -56,6 +57,8 @@ const READY_STATUS = "reviewed";
56
57
  /** 评审关着时 run 停在这里:提案写完了、没人看过,仍然入队等人工批准 */
57
58
  const PROPOSED_STATUS = "proposed";
58
59
  const MAX_SCAN = 50;
60
+ /** 采纳链的锁上限:一次扫队列 + 应用几份 diff,正常两秒内完成;锁主崩了 5 分钟后可回收 */
61
+ const APPLY_LOCK_MS = 5 * 60_000;
59
62
 
60
63
  function readText(file: string): string | null {
61
64
  try {
@@ -225,10 +228,33 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
225
228
  return applied;
226
229
  };
227
230
 
231
+ /**
232
+ * 采纳链(扫队列 → 应用)跨实例互斥。
233
+ *
234
+ * 为什么需要:同一个工作区开两个 pi 时,两边的 `session_start` / `settle` 都会扫同一份 queue.json。
235
+ * 队列是「读-改-写」,两个进程同时写会互相覆盖;`autoApplyPending` 更糟 —— 同一份 diff 可能被
236
+ * 两个进程各应用一次。抢不到锁的那一方直接跳过,下一次 settle / 会话起点还会再来。
237
+ */
238
+ const withApplyLock = async (run: () => Promise<unknown>): Promise<void> => {
239
+ const got = await withLock(
240
+ {
241
+ evolutionDir: deps.evolutionDir,
242
+ name: "apply",
243
+ owner: { pid: process.pid, ts: now() },
244
+ maxRuntimeMs: APPLY_LOCK_MS,
245
+ log,
246
+ },
247
+ run,
248
+ );
249
+ if (!got.ok) {
250
+ log(`[skill-evolve] 采纳链由另一个 pi 实例持有(locks/apply pid ${got.owner?.pid ?? "?"}),本次跳过`);
251
+ }
252
+ };
253
+
228
254
  const scanAndApply = (): void => {
229
- void enqueueReady()
230
- .then(() => autoApplyPending())
231
- .catch((error: unknown) => log(`[skill-evolve] 采纳扫描失败:${String(error)}`));
255
+ void withApplyLock(() => enqueueReady().then(() => autoApplyPending())).catch((error: unknown) =>
256
+ log(`[skill-evolve] 采纳扫描失败:${String(error)}`),
257
+ );
232
258
  };
233
259
 
234
260
  const sessionStart: ApplyTools["sessionStart"] = () => {
@@ -239,8 +265,7 @@ export function createApplyTools(deps: ApplyToolsDeps): ApplyTools {
239
265
  log(`[skill-evolve] 备份清理失败:${String(error)}`);
240
266
  }
241
267
  void saveGate0(deps.evolutionDir, captureGate0(deps.git, deps.cwd, time), deps.queue)
242
- .then(() => enqueueReady())
243
- .then(() => autoApplyPending())
268
+ .then(() => withApplyLock(() => enqueueReady().then(() => autoApplyPending())))
244
269
  .catch((error: unknown) => log(`[skill-evolve] 会话起点初始化失败:${String(error)}`));
245
270
  };
246
271
 
@@ -97,7 +97,7 @@ export interface Evolution {
97
97
  * 重启 / `/reload` 后的接手:补状态、重挂督导师(详见 lib/recover.ts)。
98
98
  * 返回本次做了什么,面板与日志都靠它告诉用户「后台任务没丢」。
99
99
  */
100
- recover(): Promise<RecoveryAction[]>;
100
+ recover(sessionId?: string): Promise<RecoveryAction[]>;
101
101
  /** 最近一次恢复的结果(面板可视化用;本进程内有效) */
102
102
  lastRecovery(): RecoveryAction[];
103
103
  }
@@ -283,7 +283,7 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
283
283
  * 同步抛错也在这里吞:会话起点不能被恢复流程搞挂。
284
284
  */
285
285
  let lastActions: RecoveryAction[] = [];
286
- const recover = async (): Promise<RecoveryAction[]> => {
286
+ const recover = async (sessionId?: string): Promise<RecoveryAction[]> => {
287
287
  try {
288
288
  lastActions = await recoverRuns({
289
289
  evolutionDir,
@@ -295,6 +295,7 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
295
295
  reviewModel: modelTools.reviewModel,
296
296
  ...(deps.alive ? { alive: deps.alive } : {}),
297
297
  ...(deps.supervisorSpawnFn ? { spawnSupervisor: deps.supervisorSpawnFn } : {}),
298
+ ...(sessionId ? { sessionId } : {}),
298
299
  log,
299
300
  });
300
301
  } catch (error) {
@@ -27,8 +27,11 @@ export function isPidAlive(pid: number): boolean {
27
27
  }
28
28
  }
29
29
 
30
- export function lockDir(evolutionDir: string): string {
31
- return path.join(evolutionDir, "locks", "dispatch");
30
+ /** 锁名 → 目录名。`dispatch` 是复盘派发的老锁;`supervisor` / `apply` 是 0.1.13 加的跨实例锁 */
31
+ export type LockName = "dispatch" | "supervisor" | "apply";
32
+
33
+ export function lockDir(evolutionDir: string, name: LockName = "dispatch"): string {
34
+ return path.join(evolutionDir, "locks", name);
32
35
  }
33
36
 
34
37
  /**
@@ -71,6 +74,8 @@ export async function readOwner(dir: string): Promise<LockOwner | null> {
71
74
 
72
75
  export interface AcquireOptions {
73
76
  evolutionDir: string;
77
+ /** 哪把锁,缺省 `dispatch`(老行为不变) */
78
+ name?: LockName;
74
79
  owner: LockOwner;
75
80
  maxRuntimeMs: number;
76
81
  log?: (msg: string) => void;
@@ -86,7 +91,7 @@ export interface AcquireResult {
86
91
 
87
92
  export async function acquireLock(options: AcquireOptions): Promise<AcquireResult> {
88
93
  const log = options.log ?? (() => {});
89
- const dir = lockDir(options.evolutionDir);
94
+ const dir = lockDir(options.evolutionDir, options.name);
90
95
  const now = options.now?.() ?? Date.now();
91
96
 
92
97
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -119,7 +124,7 @@ export async function acquireLock(options: AcquireOptions): Promise<AcquireResul
119
124
  } catch (error) {
120
125
  // 目录建好了但 owner 写不进去:立刻还回去,避免留下无人认领的锁
121
126
  log(`[skill-evolve] 写 owner.json 失败:${String(error)}`);
122
- await releaseLock(options.evolutionDir, options.log);
127
+ await releaseLock(options.evolutionDir, options.log, options.name);
123
128
  return { ok: false, why: "error" };
124
129
  }
125
130
  }
@@ -127,8 +132,12 @@ export async function acquireLock(options: AcquireOptions): Promise<AcquireResul
127
132
  }
128
133
 
129
134
  /** 释放锁(幂等):目录不在也算成功 */
130
- export async function releaseLock(evolutionDir: string, log: (msg: string) => void = () => {}): Promise<void> {
131
- const dir = lockDir(evolutionDir);
135
+ export async function releaseLock(
136
+ evolutionDir: string,
137
+ log: (msg: string) => void = () => {},
138
+ name: LockName = "dispatch",
139
+ ): Promise<void> {
140
+ const dir = lockDir(evolutionDir, name);
132
141
  try {
133
142
  await fs.promises.rm(dir, { recursive: true, force: true });
134
143
  } catch (error) {
@@ -146,7 +155,7 @@ function writeOwnerFile(dir: string, owner: LockOwner): void {
146
155
  */
147
156
  export function acquireLockSync(options: AcquireOptions): AcquireResult {
148
157
  const log = options.log ?? (() => {});
149
- const dir = lockDir(options.evolutionDir);
158
+ const dir = lockDir(options.evolutionDir, options.name);
150
159
  const now = options.now?.() ?? Date.now();
151
160
 
152
161
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -177,7 +186,7 @@ export function acquireLockSync(options: AcquireOptions): AcquireResult {
177
186
  return { ok: true };
178
187
  } catch (error) {
179
188
  log(`[skill-evolve] 写 owner.json 失败:${String(error)}`);
180
- releaseLockSync(options.evolutionDir, options.log);
189
+ releaseLockSync(options.evolutionDir, options.log, options.name);
181
190
  return { ok: false, why: "error" };
182
191
  }
183
192
  }
@@ -189,9 +198,10 @@ export function writeOwnerSync(
189
198
  evolutionDir: string,
190
199
  owner: LockOwner,
191
200
  log: (msg: string) => void = () => {},
201
+ name: LockName = "dispatch",
192
202
  ): boolean {
193
203
  try {
194
- writeOwnerFile(lockDir(evolutionDir), owner);
204
+ writeOwnerFile(lockDir(evolutionDir, name), owner);
195
205
  return true;
196
206
  } catch (error) {
197
207
  log(`[skill-evolve] 改写 owner.json 失败:${String(error)}`);
@@ -200,9 +210,13 @@ export function writeOwnerSync(
200
210
  }
201
211
 
202
212
  /** 同步放锁:只给同步补发路径用(失败也只记日志) */
203
- export function releaseLockSync(evolutionDir: string, log: (msg: string) => void = () => {}): void {
213
+ export function releaseLockSync(
214
+ evolutionDir: string,
215
+ log: (msg: string) => void = () => {},
216
+ name: LockName = "dispatch",
217
+ ): void {
204
218
  try {
205
- fs.rmSync(lockDir(evolutionDir), { recursive: true, force: true });
219
+ fs.rmSync(lockDir(evolutionDir, name), { recursive: true, force: true });
206
220
  } catch (error) {
207
221
  log(`[skill-evolve] 释放锁失败:${String(error)}`);
208
222
  }
@@ -213,3 +227,30 @@ export async function lockState(evolutionDir: string): Promise<{ held: boolean;
213
227
  const held = fs.existsSync(dir);
214
228
  return { held, owner: held ? await readOwner(dir) : null };
215
229
  }
230
+
231
+ export interface WithLockResult<T> {
232
+ ok: boolean;
233
+ value?: T;
234
+ why?: "busy" | "error";
235
+ /** 抢失败时的持有者:调用方可以据此说「另一个 pi 实例正在做」 */
236
+ owner?: LockOwner | null;
237
+ }
238
+
239
+ /**
240
+ * 跨实例互斥执行:抢到锁就执行并保证在 finally 里释放;抢不到立刻返回(不排队、不重试)。
241
+ *
242
+ * 为什么不做「等一会儿再抢」:这些锁护的都是毫秒级动作(spawn 督导、扫一遍队列采纳),
243
+ * 排队只会把两个 pi 会话互相拖慢;抢不到的那一方跳过即可,下一次 session_start 还会再来。
244
+ */
245
+ export async function withLock<T>(
246
+ options: AcquireOptions & { name: LockName },
247
+ run: () => Promise<T> | T,
248
+ ): Promise<WithLockResult<T>> {
249
+ const got = await acquireLock(options);
250
+ if (!got.ok) return { ok: false, why: got.why ?? "busy", owner: got.owner ?? null };
251
+ try {
252
+ return { ok: true, value: await run() };
253
+ } finally {
254
+ await releaseLock(options.evolutionDir, options.log, options.name);
255
+ }
256
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { withLock } from "./lock.ts";
16
17
  import { spawnReviewSupervisor, type SpawnSupervisorOptions } from "./review-spawn.ts";
17
18
  import { listRuns, updateRunStatus, type RunRecord } from "./runs.ts";
18
19
  import type { MutationQueue } from "./telemetry.ts";
@@ -56,10 +57,26 @@ export interface RecoverDeps {
56
57
  autoReview?: () => boolean;
57
58
  reviewModel?: () => string;
58
59
  spawnSupervisor?: (options: SpawnSupervisorOptions) => number | null;
60
+ /** 本会话 ID:写进锁 owner.json,排查「谁在重挂」时有用 */
61
+ sessionId?: string;
59
62
  log?: (msg: string) => void;
60
63
  windowMs?: number;
61
64
  }
62
65
 
66
+ /** 督导锁只护「起来 + 写 supervisorPid」这几毫秒,留 60 秒足够,锁主崩了也不会长期占住 */
67
+ const SUPERVISOR_LOCK_MS = 60_000;
68
+
69
+ /** 抢到锁后重新确认「这条 run 的督导确实不在」,防止两边都判死、抢完各起一个 */
70
+ function supervisorStillGone(
71
+ runDir: string,
72
+ record: RunRecord,
73
+ alive: (pid: number) => boolean,
74
+ ): boolean {
75
+ const fresh = readRecord(runDir) ?? record;
76
+ const pid = fresh.supervisorPid;
77
+ return !(typeof pid === "number" && alive(pid));
78
+ }
79
+
63
80
  function exists(runDir: string, name: string): boolean {
64
81
  try {
65
82
  return fs.statSync(path.join(runDir, name)).isFile();
@@ -146,15 +163,45 @@ export async function recoverRuns(deps: RecoverDeps): Promise<RecoveryAction[]>
146
163
  actions.push({ runId, kind: "respawn-blocked", detail: "自动评审关着:停在待人工 /skill-evolve approve" });
147
164
  continue;
148
165
  }
149
- const pid = spawnSupervisor({
150
- runDir,
151
- cwd: deps.cwd,
152
- autoReview: true,
153
- reviewModel: deps.reviewModel?.() ?? "inherit",
154
- ...(deps.env ? { env: deps.env } : {}),
155
- ...(deps.alive ? { alive: deps.alive } : {}),
156
- log,
157
- });
166
+ // 3. 跨实例互斥:同一个工作区开两个 pi 时,两边 session_start 都会走到这里。
167
+ // 单飞闸只认「已经写进 run.json 的活 pid」,两边的 spawn 之间有空档,会各起一个督导(双花 token、双写 03-review)。
168
+ // 所以先用 locks/supervisor 抢一下,抢不到说明对面那份正在重挂,让它挂。
169
+ const spawned = await withLock(
170
+ {
171
+ evolutionDir: deps.evolutionDir,
172
+ name: "supervisor",
173
+ owner: { pid: process.pid, ts: now(), ...(deps.sessionId ? { sessionId: deps.sessionId } : {}) },
174
+ maxRuntimeMs: SUPERVISOR_LOCK_MS,
175
+ log,
176
+ ...(deps.now ? { now: deps.now } : {}),
177
+ },
178
+ () =>
179
+ // 抢到锁后再判一次:对面可能刚好在抢锁之前写完 supervisorPid
180
+ supervisorStillGone(runDir, record, alive)
181
+ ? spawnSupervisor({
182
+ runDir,
183
+ cwd: deps.cwd,
184
+ autoReview: true,
185
+ reviewModel: deps.reviewModel?.() ?? "inherit",
186
+ ...(deps.env ? { env: deps.env } : {}),
187
+ ...(deps.alive ? { alive: deps.alive } : {}),
188
+ log,
189
+ })
190
+ : "taken",
191
+ );
192
+ if (!spawned.ok) {
193
+ actions.push({
194
+ runId,
195
+ kind: "respawn-blocked",
196
+ detail: `另一个 pi 实例正在重挂督导(locks/supervisor 被 pid ${spawned.owner?.pid ?? "?"} 持有)`,
197
+ });
198
+ continue;
199
+ }
200
+ const pid = spawned.value;
201
+ if (pid === "taken") {
202
+ actions.push({ runId, kind: "waiting-review", detail: "等到锁时督导已经在了(另一个实例刚挂上),不重复起" });
203
+ continue;
204
+ }
158
205
  actions.push(
159
206
  pid === null
160
207
  ? { runId, kind: "respawn-blocked", detail: "督导没起来(单飞闸占满或 spawn 失败),下次会话起点还会再试" }
@@ -14,7 +14,7 @@ import * as fs from "node:fs";
14
14
  import * as os from "node:os";
15
15
  import * as path from "node:path";
16
16
 
17
- import { acquireLock, acquireLockSync, isPidAlive, lockDir, readOwnerSync, releaseLock, releaseLockSync, shouldReclaim, writeOwnerSync } from "../lib/lock.ts";
17
+ import { acquireLock, acquireLockSync, isPidAlive, lockDir, readOwnerSync, releaseLock, releaseLockSync, shouldReclaim, withLock, writeOwnerSync } from "../lib/lock.ts";
18
18
 
19
19
  const MAX = 20 * 60_000;
20
20
  const NOW = 1_760_000_000_000;
@@ -136,3 +136,40 @@ test("writeOwnerSync:把持有者换成子进程 pid,供 status 与孤儿判
136
136
  assert.deepEqual(readOwnerSync(lockDir(dir)), { pid: 4321, ts: NOW + 1, sessionId: "s1" });
137
137
  releaseLockSync(dir);
138
138
  });
139
+
140
+ test("lockDir:不同名字的锁互不干扰(派发锁被占不影响督导锁)", async () => {
141
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-lock-name-"));
142
+ const base = { evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW };
143
+ const dispatch = await acquireLock(base);
144
+ assert.equal(dispatch.ok, true);
145
+ const supervisor = await acquireLock({ ...base, name: "supervisor" });
146
+ assert.equal(supervisor.ok, true, "另一个名字的锁应当能独立抢到");
147
+ assert.equal(fs.existsSync(path.join(dir, "locks", "supervisor")), true);
148
+ await releaseLock(dir, () => {}, "supervisor");
149
+ await releaseLock(dir);
150
+ assert.equal(fs.existsSync(path.join(dir, "locks")), true, "locks 父目录留着,空的子目录删干净");
151
+ assert.deepEqual(fs.readdirSync(path.join(dir, "locks")), []);
152
+ });
153
+
154
+ test("withLock:忙时直接返回 why=busy 不执行;跑完一定释放(抛错也释放)", async () => {
155
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-withlock-"));
156
+ const options = { evolutionDir: dir, name: "apply" as const, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW };
157
+ const first = await withLock(options, async () => {
158
+ const inner = await withLock(options, () => "不该执行");
159
+ assert.equal(inner.ok, false);
160
+ assert.equal(inner.why, "busy");
161
+ return "done";
162
+ });
163
+ assert.equal(first.ok, true);
164
+ assert.equal(first.value, "done");
165
+ // 异常要往上抛(调用方自己决定记什么日志),但锁必须已经放掉
166
+ await assert.rejects(
167
+ withLock(options, () => {
168
+ throw new Error("模拟采纳中途崩");
169
+ }),
170
+ /模拟采纳中途崩/,
171
+ );
172
+ assert.equal(fs.existsSync(path.join(dir, "locks", "apply")), false, "抛错也要释放,否则下次永远 busy");
173
+ const again = await withLock(options, () => "又抢到了");
174
+ assert.equal(again.ok, true);
175
+ });
@@ -151,3 +151,27 @@ test("面板文案带 kinds 与明细", () => {
151
151
  assert.match(text, /r-1 {2}重挂督导 — 重挂 pid 9/);
152
152
  assert.match(text, /r-2 {2}已中断 — 没提案/);
153
153
  });
154
+
155
+ test("一个工作区两个 pi 同时启动恢复:督导只起一个(另一个看到锁里已经挂上了)", async () => {
156
+ const { evolutionDir, write } = setup();
157
+ write("r-9", { status: "analyzed", pid: null, supervisorPid: null }, ["01-findings.json", "02-proposal.json"]);
158
+ let spawns = 0;
159
+ const spawnSupervisor = (options: SpawnSupervisorOptions): number => {
160
+ spawns += 1;
161
+ // 模拟真实现:spawn 之后立刻把 supervisorPid 写回 run.json(这就是后到者的判断依据)
162
+ const file = path.join(options.runDir, "run.json");
163
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
164
+ fs.writeFileSync(file, JSON.stringify({ ...raw, supervisorPid: process.pid }, null, 2));
165
+ return process.pid;
166
+ };
167
+ // 督导 pid 就是本进程 pid:重挂后它「活着」,这正是后到者的判断依据
168
+ const deps = base({ evolutionDir, spawnSupervisor, alive: (pid) => pid === process.pid });
169
+ const both = await Promise.all([recoverRuns(deps), recoverRuns(deps)]);
170
+ assert.equal(spawns, 1, "两个实例各起一个督导 = 双花 token + 双写 03-review");
171
+ const kinds = both.flat().map((action) => action.kind);
172
+ assert.ok(
173
+ kinds.includes("supervisor-respawned") || kinds.includes("respawn-blocked"),
174
+ `应当有人挂上、有人让位(实际 ${kinds.join(",")})`,
175
+ );
176
+ assert.ok(!kinds.includes("interrupted"), "提案在、督导被重挂,不该判成中断");
177
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-evolve",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "技能进化:采集会话中的技能曝光/加载,异步复盘、生成改进提案并独立评审,附本机 Web 管理面板",
5
5
  "keywords": [
6
6
  "pi-package",