@lmzhen/dsh-evolution-approval 0.3.81 → 0.3.82

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/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { isProcessAlive, parseLockBody } from "@lmzhen/dsh-evolution-core";
3
4
  import z from "@deepseek-ai/schemastery";
4
5
  //#region lib/types/index.js
5
6
  /**
@@ -135,14 +136,25 @@ var EvolutionApproval = class extends Service {
135
136
  return await this.dedupe(`approve:${id}`, () => this.doApprove(id));
136
137
  }
137
138
  /** S2-P2-22 (0.3.80): return an ORPHANED executing record to the pending
138
- * window. "Orphaned" = status `executing` with no approve running in THIS
139
- * process (the family single-instance claim rules out any other live
140
- * process, so a claim that is not in this service's in-flight dedupe map
141
- * belongs to a dead one). The stored `claimedBy` rides along as the seam's
142
- * release credential, so no state-seam expansion is needed. The operator is
143
- * expected to verify the effect first: re-approving replays the write
144
- * deliberately (duplicates possible if the effect already landed);
145
- * rejecting closes the record. */
139
+ * window. "Orphaned" is DECIDED here, never assumed: the older rationale
140
+ * ("the family single-instance claim rules out any other live process") was
141
+ * wrong (v43 FLOW2-1) that claim is a module-scope Map in
142
+ * core/instance-scope.ts, i.e. PER PROCESS, so a second process over one home
143
+ * is not excluded at all. Two checks stand in its place:
144
+ * 1. `inFlight` an approve/reject running in THIS service instance;
145
+ * 2. the claim's holder pid. Claim ids are minted as `<pid>:<hex token>`, the
146
+ * same body shape the io write lock uses, so `parseLockBody` + the core
147
+ * `isProcessAlive` probe answer whether that holder can still be running: a
148
+ * LIVE FOREIGN pid REFUSES the release; our own pid is this process's
149
+ * leftover (a live in-process approve is what `inFlight` covers); a dead pid
150
+ * is a crash. The last two are releasable, which is the S2-P2-22 case.
151
+ * A claim carrying no pid (the bare-UUID claims minted before this change)
152
+ * cannot be probed: the release then degrades to an explicitly DESTRUCTIVE
153
+ * operator action and says so — re-approving replays the write, and that
154
+ * replay is NOT idempotent if the effect already landed. The operator is
155
+ * expected to verify the effect first; rejecting closes the record instead
156
+ * (the stored `claimedBy` rides along as the seam's release credential, so no
157
+ * state-seam expansion is needed). */
146
158
  async release(id) {
147
159
  const executing = (await this.state().listPending("executing")).find((item) => item.id === id);
148
160
  if (!executing) {
@@ -164,15 +176,32 @@ var EvolutionApproval = class extends Service {
164
176
  ok: false,
165
177
  message: `Pending write "${id}" carries no claim to release (unexpected record shape) — reject it instead.`
166
178
  };
167
- await this.state().releasePendingClaim(id, claimId);
179
+ const holderPid = parseLockBody(claimId);
180
+ if (holderPid !== null && holderPid !== process.pid && isProcessAlive(holderPid)) return {
181
+ ok: false,
182
+ message: `Pending write "${id}" is claimed by pid ${holderPid}, which is ALIVE — another process may be running this approve. Do not release it: verify the write effect instead and let that run finish (a completed approve resolves its own record).`
183
+ };
184
+ let released = false;
185
+ let failure = "";
186
+ try {
187
+ await this.state().releasePendingClaim(id, claimId);
188
+ released = (await this.state().listPending("pending")).some((item) => item.id === id);
189
+ } catch (error) {
190
+ failure = error instanceof Error ? error.message : String(error);
191
+ this.ctx.logger.warn(error);
192
+ }
193
+ if (!released) return {
194
+ ok: false,
195
+ message: `Release of "${id}" did not take effect (${failure === "" ? "the claim no longer matches the record — a concurrent writer moved it" : failure}) — it stays EXECUTING with its claim unless it resolved, and the write effect is still unverified. Retry, or reject it to close the record without replaying.`
196
+ };
168
197
  return {
169
198
  ok: true,
170
- message: `Released "${id}" back to the pending window. VERIFY the effect first: approve re-runs the write deliberately (duplicates possible if the effect already landed); reject closes the record.`
199
+ message: `Released "${id}" back to the pending window.${holderPid === null ? " Its claim carries no pid, so liveness could NOT be verified: another process may still be running this approve." : ""} VERIFY the effect before approving: approve re-runs the write, and a non-idempotent replay duplicates an effect that already landed; reject closes the record instead.`
171
200
  };
172
201
  }
173
202
  async reject(id) {
174
203
  return await this.dedupe(`reject:${id}`, async () => {
175
- const claimId = randomUUID();
204
+ const claimId = this.newClaimId();
176
205
  if (!await this.state().claimPending(id, claimId)) {
177
206
  if ((await this.state().listPending("executing")).find((item) => item.id === id)) return (await this.state().tryResolvePending(id, "rejected")).applied ? {
178
207
  ok: true,
@@ -217,6 +246,15 @@ var EvolutionApproval = class extends Service {
217
246
  if (override === "never" || override === "ask") return override;
218
247
  return platformApproval.config?.policy ?? "ask";
219
248
  }
249
+ /** v43 FLOW2-1: a claim id that CARRIES its holder pid — `parseLockBody`
250
+ * reads it back and `isProcessAlive` turns it into `release()`'s verifiable
251
+ * orphan criterion. The `<pid>:<hex token>` body is the io write lock's own
252
+ * shape, so one set of core helpers reads both; the token stays unique per
253
+ * call, so two approves of one record can never share a claim (the token is a
254
+ * dashed UUID stripped to hex because `LOCK_BODY_RE` accepts only hex). */
255
+ newClaimId() {
256
+ return `${process.pid}:${randomUUID().split("-").join("")}`;
257
+ }
220
258
  dedupe(id, task) {
221
259
  const existing = this.inFlight.get(id);
222
260
  if (existing) return existing;
@@ -227,7 +265,7 @@ var EvolutionApproval = class extends Service {
227
265
  return run;
228
266
  }
229
267
  async doApprove(id) {
230
- const claimId = randomUUID();
268
+ const claimId = this.newClaimId();
231
269
  const record = await this.state().claimPending(id, claimId);
232
270
  if (!record) {
233
271
  if ((await this.state().listPending("executing")).find((item) => item.id === id)) return {
@@ -97,7 +97,11 @@ export type ApprovalLike = {
97
97
  }>;
98
98
  /** S2-P2-22 (0.3.80): return an ORPHANED executing record (an approve that
99
99
  * crashed mid-run) to the pending window, so it can be deliberately
100
- * re-approved or rejected. Optional: only the real service implements it. */
100
+ * re-approved or rejected. v43 FLOW2-1: "orphaned" is DECIDED from this
101
+ * process's in-flight set AND the holder pid the record's claim carries — so a
102
+ * claim naming a live foreign pid is REFUSED, and a claim without a pid is
103
+ * released only as an explicitly destructive operator action. Optional: only
104
+ * the real service implements it. */
101
105
  release?(id: string): Promise<{
102
106
  ok: boolean;
103
107
  message: string;
@@ -164,14 +168,25 @@ export declare class EvolutionApproval extends Service {
164
168
  message: string;
165
169
  }>;
166
170
  /** S2-P2-22 (0.3.80): return an ORPHANED executing record to the pending
167
- * window. "Orphaned" = status `executing` with no approve running in THIS
168
- * process (the family single-instance claim rules out any other live
169
- * process, so a claim that is not in this service's in-flight dedupe map
170
- * belongs to a dead one). The stored `claimedBy` rides along as the seam's
171
- * release credential, so no state-seam expansion is needed. The operator is
172
- * expected to verify the effect first: re-approving replays the write
173
- * deliberately (duplicates possible if the effect already landed);
174
- * rejecting closes the record. */
171
+ * window. "Orphaned" is DECIDED here, never assumed: the older rationale
172
+ * ("the family single-instance claim rules out any other live process") was
173
+ * wrong (v43 FLOW2-1) that claim is a module-scope Map in
174
+ * core/instance-scope.ts, i.e. PER PROCESS, so a second process over one home
175
+ * is not excluded at all. Two checks stand in its place:
176
+ * 1. `inFlight` an approve/reject running in THIS service instance;
177
+ * 2. the claim's holder pid. Claim ids are minted as `<pid>:<hex token>`, the
178
+ * same body shape the io write lock uses, so `parseLockBody` + the core
179
+ * `isProcessAlive` probe answer whether that holder can still be running: a
180
+ * LIVE FOREIGN pid REFUSES the release; our own pid is this process's
181
+ * leftover (a live in-process approve is what `inFlight` covers); a dead pid
182
+ * is a crash. The last two are releasable, which is the S2-P2-22 case.
183
+ * A claim carrying no pid (the bare-UUID claims minted before this change)
184
+ * cannot be probed: the release then degrades to an explicitly DESTRUCTIVE
185
+ * operator action and says so — re-approving replays the write, and that
186
+ * replay is NOT idempotent if the effect already landed. The operator is
187
+ * expected to verify the effect first; rejecting closes the record instead
188
+ * (the stored `claimedBy` rides along as the seam's release credential, so no
189
+ * state-seam expansion is needed). */
175
190
  release(id: string): Promise<{
176
191
  ok: boolean;
177
192
  message: string;
@@ -190,6 +205,13 @@ export declare class EvolutionApproval extends Service {
190
205
  * check is lazy: the platform service can start before or after this plugin.
191
206
  */
192
207
  private deriveSessionPolicy;
208
+ /** v43 FLOW2-1: a claim id that CARRIES its holder pid — `parseLockBody`
209
+ * reads it back and `isProcessAlive` turns it into `release()`'s verifiable
210
+ * orphan criterion. The `<pid>:<hex token>` body is the io write lock's own
211
+ * shape, so one set of core helpers reads both; the token stays unique per
212
+ * call, so two approves of one record can never share a claim (the token is a
213
+ * dashed UUID stripped to hex because `LOCK_BODY_RE` accepts only hex). */
214
+ private newClaimId;
193
215
  private dedupe;
194
216
  private doApprove;
195
217
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-approval",
3
3
  "description": "Stage/pending approval service for Hermes-style self-evolution writes (community build)",
4
- "version": "0.3.81",
4
+ "version": "0.3.82",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -26,15 +26,17 @@
26
26
  ],
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@deepseek-ai/schemastery": "^3.18.1"
29
+ "@deepseek-ai/schemastery": "^3.18.1",
30
+ "@lmzhen/dsh-evolution-core": "^0.3.82"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@deepseek-ai/cordis": "^4.0.1",
33
- "@lmzhen/dsh-evolution-state-storage": "^0.3.81",
34
- "@lmzhen/dsh-evolution-state": "^0.3.81"
34
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.82",
35
+ "@lmzhen/dsh-evolution-state": "^0.3.82"
35
36
  },
36
37
  "devDependencies": {
37
- "@lmzhen/dsh-evolution-state-storage": "^0.3.81",
38
- "@lmzhen/dsh-evolution-state": "^0.3.81"
38
+ "@lmzhen/dsh-evolution-core": "^0.3.82",
39
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.82",
40
+ "@lmzhen/dsh-evolution-state": "^0.3.82"
39
41
  }
40
42
  }