@lmzhen/dsh-evolution-core 0.19.2 → 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,43 +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
- /** The ONE wording of the orphan verdict — read-time convergence says it in two places. */
6064
+ /** The ONE wording of the orphan verdict. */
6053
6065
  const ORPHAN_FAILURE = "no live owner process — the run was interrupted before it settled (orphan)";
6054
- /** A `running` record whose owner is gone, as the index must show it. */
6055
- const orphanOf = (record) => ({
6056
- ...record,
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,
6057
6071
  state: "failed",
6058
6072
  endedAt: now(),
6059
6073
  failure: ORPHAN_FAILURE
6060
6074
  });
6061
- /** Keep every RUNNING record plus the newest terminal ones: memory and the
6062
- * index share one cap, so the two can never disagree about the history. */
6063
- const trim = () => {
6064
- const terminal = records.filter((record) => TERMINAL.has(record.state)).sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
6065
- for (const stale of terminal.slice(maxRecords)) {
6066
- const index = records.indexOf(stale);
6067
- if (index >= 0) records.splice(index, 1);
6068
- }
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;
6069
6105
  };
6070
6106
  const persist = async () => {
6071
- trim();
6072
6107
  try {
6073
6108
  await transactIo(options.io, path, (current) => {
6074
6109
  const onDisk = parseIndex(current);
6075
6110
  const merged = /* @__PURE__ */ new Map();
6076
- for (const record of onDisk.records) merged.set(record.id, record);
6077
- for (const record of records) {
6078
- const onDiskRow = merged.get(record.id);
6079
- const settledElsewhere = onDiskRow !== void 0 && TERMINAL.has(onDiskRow.state) && !TERMINAL.has(record.state);
6080
- if (!controllers.has(record.id) && settledElsewhere) continue;
6081
- merged.set(record.id, record);
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);
6082
6116
  }
6083
- const view = [...merged.values()].sort((a, b) => b.startedAt - a.startedAt);
6084
- const live = view.filter((record) => record.state === "running");
6085
- const done = view.filter((record) => TERMINAL.has(record.state)).slice(0, maxRecords);
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));
6086
6121
  return JSON.stringify({
6087
6122
  schemaVersion: 1,
6088
6123
  runs: [
@@ -6112,45 +6147,36 @@ function newRunRegistry(options) {
6112
6147
  ok: false,
6113
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)"
6114
6149
  };
6150
+ const wasRunning = new Set([...foreign.values()].filter((row) => row.state === "running").map((row) => row.id));
6151
+ const seen = /* @__PURE__ */ new Set();
6115
6152
  let converged = 0;
6116
6153
  let liveElsewhere = 0;
6117
- let healed = 0;
6118
- for (const record of parsed.records) {
6119
- const knownIndex = records.findIndex((item) => item.id === record.id);
6120
- if (knownIndex >= 0) {
6121
- const known = records[knownIndex];
6122
- if (!controllers.has(record.id) && !TERMINAL.has(known.state)) {
6123
- if (TERMINAL.has(record.state)) {
6124
- delete known.endedAt;
6125
- delete known.resultRef;
6126
- delete known.failure;
6127
- Object.assign(known, record);
6128
- healed++;
6129
- } else if (record.pid === void 0 || !isProcessAlive(record.pid)) {
6130
- records[knownIndex] = orphanOf(record);
6131
- converged++;
6132
- }
6133
- }
6134
- continue;
6135
- }
6136
- if (record.state !== "running") {
6137
- records.push(record);
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++;
6138
6162
  continue;
6139
6163
  }
6140
- if (record.pid !== void 0 && isProcessAlive(record.pid)) {
6141
- liveElsewhere++;
6142
- records.push(record);
6164
+ if (ownerIsGone(row)) {
6165
+ converged++;
6166
+ adopt(orphanOf(row));
6143
6167
  continue;
6144
6168
  }
6145
- converged++;
6146
- records.push(orphanOf(record));
6169
+ liveElsewhere++;
6170
+ if (adopt(row)) regressed++;
6147
6171
  }
6148
- 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();
6149
6174
  const notes = [
6150
6175
  parsed.note,
6151
6176
  converged > 0 ? `${converged} run(s) with no live owner were recorded as failed(orphan)` : void 0,
6152
6177
  liveElsewhere > 0 ? `${liveElsewhere} run(s) are in flight in another process sharing this home` : void 0,
6153
- 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
6154
6180
  ].filter((note) => note !== void 0);
6155
6181
  return {
6156
6182
  ok: true,
@@ -6162,7 +6188,7 @@ function newRunRegistry(options) {
6162
6188
  const startedAt = now();
6163
6189
  const controller = new AbortController();
6164
6190
  controllers.set(id, controller);
6165
- records.push({
6191
+ owned.set(id, {
6166
6192
  id,
6167
6193
  kind,
6168
6194
  state: "running",
@@ -6178,37 +6204,37 @@ function newRunRegistry(options) {
6178
6204
  };
6179
6205
  },
6180
6206
  async settle(id, outcome) {
6181
- const record = records.find((item) => item.id === id);
6182
- if (!record || TERMINAL.has(record.state)) return;
6183
- record.state = outcome.state;
6184
- record.endedAt = now();
6185
- if (outcome.resultRef !== void 0) record.resultRef = outcome.resultRef;
6186
- 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;
6187
6213
  controllers.delete(id);
6188
6214
  await persist();
6189
6215
  },
6190
6216
  inFlight() {
6191
- return ordered().find((record) => record.state === "running");
6217
+ return view().find((row) => row.state === "running");
6192
6218
  },
6193
6219
  lastSettledAt() {
6194
- return ordered().find((record) => TERMINAL.has(record.state))?.endedAt;
6220
+ return view().find((row) => TERMINAL.has(row.state))?.endedAt;
6195
6221
  },
6196
6222
  runs() {
6197
- return ordered();
6223
+ return capped(view());
6198
6224
  },
6199
6225
  find(id) {
6200
- return records.find((record) => record.id === id);
6226
+ return owned.get(id) ?? foreign.get(id);
6201
6227
  },
6202
6228
  cancel(id) {
6203
6229
  const controller = controllers.get(id);
6204
6230
  if (controller === void 0) return false;
6205
- const record = records.find((item) => item.id === id);
6206
- if (!record || record.state !== "running") return false;
6231
+ const row = owned.get(id);
6232
+ if (row === void 0 || row.state !== "running") return false;
6207
6233
  controller.abort();
6208
6234
  controllers.delete(id);
6209
- record.state = "cancelled";
6210
- record.endedAt = now();
6211
- record.failure = "cancelled by operator";
6235
+ row.state = "cancelled";
6236
+ row.endedAt = now();
6237
+ row.failure = "cancelled by operator";
6212
6238
  persist();
6213
6239
  return true;
6214
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.2",
4
+ "version": "0.19.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },