@lmzhen/dsh-evolution-core 0.19.1 → 0.19.3

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
@@ -5927,7 +5927,7 @@ function sweepReviewChannelSessions(isAlive) {
5927
5927
  //#endregion
5928
5928
  //#region lib/types/run-registry.js
5929
5929
  /**
5930
- * 0.19.0 (S2): the family's run-lifecycle owner.
5930
+ * 0.19.0 (S2) / 0.19.3 (restructure): the family's run-lifecycle owner.
5931
5931
  *
5932
5932
  * A "run" is work that can outlive one interaction — today the maintenance scan.
5933
5933
  * Before this module the only record of such work was the requesting invocation:
@@ -5940,16 +5940,25 @@ function sweepReviewChannelSessions(isAlive) {
5940
5940
  * ended, and (c) WHERE its result lives (`resultRef` is a path, never the result
5941
5941
  * itself — the report file is the one result home).
5942
5942
  *
5943
- * Two disciplines are structural, not stylistic:
5944
- *
5945
- * - **No timer.** A run whose process died is converged at READ time: the index
5946
- * file can only carry `running` records written by a PREVIOUS process life,
5947
- * because the live ones are in memory. `load()` therefore rewrites every
5948
- * `running` record as `failed(orphan)` once — the same "converge when read"
5949
- * posture the approval window uses for its TTL.
5950
- * - **One terminal state.** `settle()` refuses to overwrite a record that is
5951
- * already terminal, so a cancel that raced a failure cannot flip the answer
5952
- * the operator already read.
5943
+ * Three disciplines are structural, not stylistic:
5944
+ *
5945
+ * - **One writer per row** (0.19.3). A run's row is authoritative in the memory of
5946
+ * the process that STARTED it (`owned`) and in the shared index for everyone else
5947
+ * (`foreign`, rebuilt from the file on every `load()`). The only write a non-owner
5948
+ * may make is the orphan verdict for a row whose owner is gone, judged inside the
5949
+ * very transaction that read the row. The two cross-plane bugs this family shipped
5950
+ * were projections of that one missing rule: 0.19.0 froze a foreign run as
5951
+ * `running` forever (a "known id" was never re-read), and 0.19.1 wrote a settled
5952
+ * foreign row back as `running` (memory overwrote every row it knew).
5953
+ * - **No timer.** A run whose process died is converged at READ time: the live rows
5954
+ * are in memory, so a `running` row in the file whose owner is not alive is an
5955
+ * orphan — the same "converge when read" posture the approval window uses.
5956
+ * - **One terminal state, and it is monotone.** `settle()` refuses to overwrite a row
5957
+ * that is already terminal (so a cancel that raced a failure cannot flip the answer
5958
+ * the operator already read), and a row that reached a terminal state never reads as
5959
+ * `running` again: only the owner that started it could make it running and
5960
+ * `settle()` is terminal-once, so a `running` claim for a settled row can only be a
5961
+ * stale writer's — refused on read, repaired on the next write.
5953
5962
  *
5954
5963
  * The store is PER INSTANCE (a factory, not module scope): one registry per
5955
5964
  * mounted row, disposed with its fiber. There is no module-scope mutable state
@@ -6046,29 +6055,69 @@ function newRunRegistry(options) {
6046
6055
  const maxRecords = clampedNumber(options.maxRecords ?? 50, 50, { min: 1 });
6047
6056
  const warn = options.warn ?? (() => {});
6048
6057
  const path = runsFile(options.home);
6049
- const records = [];
6058
+ /** Rows THIS process started: its memory is the only truth for them. */
6059
+ const owned = /* @__PURE__ */ new Map();
6060
+ /** Rows read from the shared index: the file is the only truth for them. */
6061
+ const foreign = /* @__PURE__ */ new Map();
6062
+ /** Cancellation handles — "can this still be stopped", NOT ownership (`settle` drops one). */
6050
6063
  const controllers = /* @__PURE__ */ new Map();
6051
- const ordered = () => [...records].sort((a, b) => b.startedAt - a.startedAt);
6052
- /** Keep every RUNNING record plus the newest terminal ones: memory and the
6053
- * index share one cap, so the two can never disagree about the history. */
6054
- const trim = () => {
6055
- const terminal = records.filter((record) => TERMINAL.has(record.state)).sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
6056
- for (const stale of terminal.slice(maxRecords)) {
6057
- const index = records.indexOf(stale);
6058
- if (index >= 0) records.splice(index, 1);
6059
- }
6064
+ /** The ONE wording of the orphan verdict. */
6065
+ const ORPHAN_FAILURE = "no live owner process — the run was interrupted before it settled (orphan)";
6066
+ /** A row whose owner cannot be reached: `pid` absent (an older build) or not alive. */
6067
+ const ownerIsGone = (row) => row.pid === void 0 || !isProcessAlive(row.pid);
6068
+ /** The verdict is materialized ONCE, so its `endedAt` (and the duration a reader sees) is stable. */
6069
+ const orphanOf = (row) => ({
6070
+ ...row,
6071
+ state: "failed",
6072
+ endedAt: now(),
6073
+ failure: ORPHAN_FAILURE
6074
+ });
6075
+ /** Every row this process knows, newest first. ALL reads go through here. */
6076
+ const view = () => [...owned.values(), ...foreign.values()].sort((a, b) => b.startedAt - a.startedAt);
6077
+ /** Retained history: every running row plus the newest terminal ones (one cap, both planes). */
6078
+ const capped = (rows) => {
6079
+ const live = rows.filter((row) => row.state === "running");
6080
+ const done = rows.filter((row) => TERMINAL.has(row.state)).sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt)).slice(0, maxRecords);
6081
+ return [...live, ...done].sort((a, b) => b.startedAt - a.startedAt);
6082
+ };
6083
+ /**
6084
+ * Update a surviving foreign row in place (identity stable), or add it.
6085
+ *
6086
+ * Monotone states (0.19.3): a row already known TERMINAL is never shown as `running`
6087
+ * again. Only the owner that started a run could make it running, and `settle()` is
6088
+ * terminal-once — so a `running` claim for a settled row can only come from a stale
6089
+ * writer (an older plane holding an outdated view), and adopting it would propagate the
6090
+ * resurrection to every other plane.
6091
+ * @returns true when the row was refused as exactly that regression.
6092
+ */
6093
+ const adopt = (row) => {
6094
+ const existing = foreign.get(row.id);
6095
+ if (existing === void 0) {
6096
+ foreign.set(row.id, row);
6097
+ return false;
6098
+ }
6099
+ if (TERMINAL.has(existing.state) && !TERMINAL.has(row.state)) return true;
6100
+ delete existing.endedAt;
6101
+ delete existing.resultRef;
6102
+ delete existing.failure;
6103
+ Object.assign(existing, row);
6104
+ return false;
6060
6105
  };
6061
6106
  const persist = async () => {
6062
- trim();
6063
6107
  try {
6064
6108
  await transactIo(options.io, path, (current) => {
6065
6109
  const onDisk = parseIndex(current);
6066
6110
  const merged = /* @__PURE__ */ new Map();
6067
- for (const record of onDisk.records) merged.set(record.id, record);
6068
- for (const record of records) merged.set(record.id, record);
6069
- const view = [...merged.values()].sort((a, b) => b.startedAt - a.startedAt);
6070
- const live = view.filter((record) => record.state === "running");
6071
- const done = view.filter((record) => TERMINAL.has(record.state)).slice(0, maxRecords);
6111
+ for (const row of onDisk.records) merged.set(row.id, row);
6112
+ for (const [id, row] of owned) merged.set(id, row);
6113
+ for (const [id, row] of foreign) {
6114
+ const onDiskRow = merged.get(id);
6115
+ if (TERMINAL.has(row.state) && onDiskRow !== void 0 && !TERMINAL.has(onDiskRow.state)) merged.set(id, row);
6116
+ }
6117
+ for (const [id, row] of merged) if (row.state === "running" && !owned.has(id) && ownerIsGone(row)) merged.set(id, orphanOf(row));
6118
+ const kept = capped([...merged.values()]);
6119
+ const live = kept.filter((row) => row.state === "running");
6120
+ const done = kept.filter((row) => TERMINAL.has(row.state));
6072
6121
  return JSON.stringify({
6073
6122
  schemaVersion: 1,
6074
6123
  runs: [
@@ -6098,52 +6147,36 @@ function newRunRegistry(options) {
6098
6147
  ok: false,
6099
6148
  note: "run index could not be read as an index (not JSON, or no runs array) — the previous run history is unknown (the reports are unaffected)"
6100
6149
  };
6150
+ const wasRunning = new Set([...foreign.values()].filter((row) => row.state === "running").map((row) => row.id));
6151
+ const seen = /* @__PURE__ */ new Set();
6101
6152
  let converged = 0;
6102
6153
  let liveElsewhere = 0;
6103
- let healed = 0;
6104
- for (const record of parsed.records) {
6105
- const knownIndex = records.findIndex((item) => item.id === record.id);
6106
- if (knownIndex >= 0) {
6107
- const known = records[knownIndex];
6108
- if (!controllers.has(record.id) && !TERMINAL.has(known.state)) {
6109
- if (TERMINAL.has(record.state)) {
6110
- records[knownIndex] = record;
6111
- healed++;
6112
- } else if (record.pid === void 0 || !isProcessAlive(record.pid)) {
6113
- records[knownIndex] = {
6114
- ...record,
6115
- state: "failed",
6116
- endedAt: now(),
6117
- failure: "no live owner process — the run was interrupted before it settled (orphan)"
6118
- };
6119
- converged++;
6120
- }
6121
- }
6154
+ let followed = 0;
6155
+ let regressed = 0;
6156
+ for (const row of parsed.records) {
6157
+ if (owned.has(row.id)) continue;
6158
+ seen.add(row.id);
6159
+ if (row.state !== "running") {
6160
+ if (wasRunning.has(row.id)) followed++;
6161
+ if (adopt(row)) regressed++;
6122
6162
  continue;
6123
6163
  }
6124
- if (record.state !== "running") {
6125
- records.push(record);
6164
+ if (ownerIsGone(row)) {
6165
+ converged++;
6166
+ adopt(orphanOf(row));
6126
6167
  continue;
6127
6168
  }
6128
- if (record.pid !== void 0 && isProcessAlive(record.pid)) {
6129
- liveElsewhere++;
6130
- records.push(record);
6131
- continue;
6132
- }
6133
- converged++;
6134
- records.push({
6135
- ...record,
6136
- state: "failed",
6137
- endedAt: now(),
6138
- failure: "no live owner process — the run was interrupted before it settled (orphan)"
6139
- });
6169
+ liveElsewhere++;
6170
+ if (adopt(row)) regressed++;
6140
6171
  }
6141
- if (converged > 0 || healed > 0) await persist();
6172
+ for (const id of [...foreign.keys()]) if (!seen.has(id)) foreign.delete(id);
6173
+ if (converged > 0 || regressed > 0) await persist();
6142
6174
  const notes = [
6143
6175
  parsed.note,
6144
6176
  converged > 0 ? `${converged} run(s) with no live owner were recorded as failed(orphan)` : void 0,
6145
6177
  liveElsewhere > 0 ? `${liveElsewhere} run(s) are in flight in another process sharing this home` : void 0,
6146
- healed > 0 ? `${healed} run(s) settled by their owner were re-read from the index` : void 0
6178
+ followed > 0 ? `${followed} run(s) settled by their owner were re-read from the index` : void 0,
6179
+ regressed > 0 ? `${regressed} settled run(s) stayed settled although the index claimed they were running` : void 0
6147
6180
  ].filter((note) => note !== void 0);
6148
6181
  return {
6149
6182
  ok: true,
@@ -6155,7 +6188,7 @@ function newRunRegistry(options) {
6155
6188
  const startedAt = now();
6156
6189
  const controller = new AbortController();
6157
6190
  controllers.set(id, controller);
6158
- records.push({
6191
+ owned.set(id, {
6159
6192
  id,
6160
6193
  kind,
6161
6194
  state: "running",
@@ -6171,37 +6204,37 @@ function newRunRegistry(options) {
6171
6204
  };
6172
6205
  },
6173
6206
  async settle(id, outcome) {
6174
- const record = records.find((item) => item.id === id);
6175
- if (!record || TERMINAL.has(record.state)) return;
6176
- record.state = outcome.state;
6177
- record.endedAt = now();
6178
- if (outcome.resultRef !== void 0) record.resultRef = outcome.resultRef;
6179
- if (outcome.failure !== void 0) record.failure = outcome.failure;
6207
+ const row = owned.get(id);
6208
+ if (row === void 0 || TERMINAL.has(row.state)) return;
6209
+ row.state = outcome.state;
6210
+ row.endedAt = now();
6211
+ if (outcome.resultRef !== void 0) row.resultRef = outcome.resultRef;
6212
+ if (outcome.failure !== void 0) row.failure = outcome.failure;
6180
6213
  controllers.delete(id);
6181
6214
  await persist();
6182
6215
  },
6183
6216
  inFlight() {
6184
- return ordered().find((record) => record.state === "running");
6217
+ return view().find((row) => row.state === "running");
6185
6218
  },
6186
6219
  lastSettledAt() {
6187
- return ordered().find((record) => TERMINAL.has(record.state))?.endedAt;
6220
+ return view().find((row) => TERMINAL.has(row.state))?.endedAt;
6188
6221
  },
6189
6222
  runs() {
6190
- return ordered();
6223
+ return capped(view());
6191
6224
  },
6192
6225
  find(id) {
6193
- return records.find((record) => record.id === id);
6226
+ return owned.get(id) ?? foreign.get(id);
6194
6227
  },
6195
6228
  cancel(id) {
6196
6229
  const controller = controllers.get(id);
6197
6230
  if (controller === void 0) return false;
6198
- const record = records.find((item) => item.id === id);
6199
- if (!record || record.state !== "running") return false;
6231
+ const row = owned.get(id);
6232
+ if (row === void 0 || row.state !== "running") return false;
6200
6233
  controller.abort();
6201
6234
  controllers.delete(id);
6202
- record.state = "cancelled";
6203
- record.endedAt = now();
6204
- record.failure = "cancelled by operator";
6235
+ row.state = "cancelled";
6236
+ row.endedAt = now();
6237
+ row.failure = "cancelled by operator";
6205
6238
  persist();
6206
6239
  return true;
6207
6240
  },
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 0.19.0 (S2): the family's run-lifecycle owner.
2
+ * 0.19.0 (S2) / 0.19.3 (restructure): the family's run-lifecycle owner.
3
3
  *
4
4
  * A "run" is work that can outlive one interaction — today the maintenance scan.
5
5
  * Before this module the only record of such work was the requesting invocation:
@@ -12,16 +12,25 @@
12
12
  * ended, and (c) WHERE its result lives (`resultRef` is a path, never the result
13
13
  * itself — the report file is the one result home).
14
14
  *
15
- * Two disciplines are structural, not stylistic:
15
+ * Three disciplines are structural, not stylistic:
16
16
  *
17
- * - **No timer.** A run whose process died is converged at READ time: the index
18
- * file can only carry `running` records written by a PREVIOUS process life,
19
- * because the live ones are in memory. `load()` therefore rewrites every
20
- * `running` record as `failed(orphan)` once — the same "converge when read"
21
- * posture the approval window uses for its TTL.
22
- * - **One terminal state.** `settle()` refuses to overwrite a record that is
23
- * already terminal, so a cancel that raced a failure cannot flip the answer
24
- * the operator already read.
17
+ * - **One writer per row** (0.19.3). A run's row is authoritative in the memory of
18
+ * the process that STARTED it (`owned`) and in the shared index for everyone else
19
+ * (`foreign`, rebuilt from the file on every `load()`). The only write a non-owner
20
+ * may make is the orphan verdict for a row whose owner is gone, judged inside the
21
+ * very transaction that read the row. The two cross-plane bugs this family shipped
22
+ * were projections of that one missing rule: 0.19.0 froze a foreign run as
23
+ * `running` forever (a "known id" was never re-read), and 0.19.1 wrote a settled
24
+ * foreign row back as `running` (memory overwrote every row it knew).
25
+ * - **No timer.** A run whose process died is converged at READ time: the live rows
26
+ * are in memory, so a `running` row in the file whose owner is not alive is an
27
+ * orphan — the same "converge when read" posture the approval window uses.
28
+ * - **One terminal state, and it is monotone.** `settle()` refuses to overwrite a row
29
+ * that is already terminal (so a cancel that raced a failure cannot flip the answer
30
+ * the operator already read), and a row that reached a terminal state never reads as
31
+ * `running` again: only the owner that started it could make it running and
32
+ * `settle()` is terminal-once, so a `running` claim for a settled row can only be a
33
+ * stale writer's — refused on read, repaired on the next write.
25
34
  *
26
35
  * The store is PER INSTANCE (a factory, not module scope): one registry per
27
36
  * mounted row, disposed with its fiber. There is no module-scope mutable state
@@ -66,9 +75,9 @@ export interface RunOutcome {
66
75
  /** The registry surface the commands row drives. */
67
76
  export interface RunRegistry {
68
77
  /**
69
- * Converge the persisted index; the commands row calls it before answering any read
70
- * (0.19.1: it also re-reads a terminal state written by the run's OWNER — see the
71
- * implementation note below).
78
+ * Converge the persisted index; the commands row calls it before answering any read.
79
+ * The foreign view is rebuilt wholesale (0.19.3), so a row another plane settled is
80
+ * re-read here instead of being frozen at whatever this process first saw.
72
81
  */
73
82
  load(): Promise<{
74
83
  ok: boolean;
@@ -88,7 +97,7 @@ export interface RunRegistry {
88
97
  inFlight(): RunRecord | undefined;
89
98
  /** When the newest terminal run ended (`undefined` when none). */
90
99
  lastSettledAt(): number | undefined;
91
- /** Every tracked run, newest first. */
100
+ /** Every tracked run, newest first (retained history: cap applied). */
92
101
  runs(): readonly RunRecord[];
93
102
  /** One run by id, converged view included. */
94
103
  find(id: string): RunRecord | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.19.1",
4
+ "version": "0.19.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },