@mnemom/mnemom 0.17.3-next.1 → 0.17.3-next.2

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.
@@ -64,6 +64,9 @@ export interface UpdateState {
64
64
  method?: InstallMethod;
65
65
  /** Channel the last successful update installed from; feeds defaultChannel. */
66
66
  followChannel?: UpdateChannel;
67
+ /** The background worker startup last spawned, while it runs (cleared when it finishes). */
68
+ workerPid?: number;
69
+ workerSince?: number;
67
70
  }
68
71
  export interface UpdatePrefs {
69
72
  channel?: UpdateChannel;
@@ -121,6 +124,19 @@ export declare function entitlementFresh(cache: EntitlementCache | undefined, no
121
124
  * registry response).
122
125
  */
123
126
  export declare function pickTarget(tags: DistTags, channel: UpdateChannel): string | undefined;
127
+ /**
128
+ * Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
129
+ * up to this long. A recorded worker older than this is treated as gone, so a
130
+ * recycled pid can't block updates.
131
+ */
132
+ export declare const WORKER_MAX_AGE_MS: number;
133
+ /**
134
+ * A known update is still pending and nothing is working on it: the last worker
135
+ * recorded `available` but was killed before installing (e.g. while waiting out
136
+ * a long agent session). Startup respawns one without waiting for the next
137
+ * scheduled check.
138
+ */
139
+ export declare function shouldResume(state: UpdateState, current: string, now: number, alive: (pid: number) => boolean): boolean;
124
140
  /** Whether the background worker should poll the registry now. */
125
141
  export declare function shouldCheck(state: UpdateState, channel: UpdateChannel, now: number): boolean;
126
142
  export declare function backoffDelay(failures: number): number;
@@ -115,6 +115,33 @@ export function pickTarget(tags, channel) {
115
115
  return next;
116
116
  return compareVersions(next, stable) >= 0 ? next : stable;
117
117
  }
118
+ /**
119
+ * Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
120
+ * up to this long. A recorded worker older than this is treated as gone, so a
121
+ * recycled pid can't block updates.
122
+ */
123
+ export const WORKER_MAX_AGE_MS = 12 * HOUR;
124
+ /**
125
+ * A known update is still pending and nothing is working on it: the last worker
126
+ * recorded `available` but was killed before installing (e.g. while waiting out
127
+ * a long agent session). Startup respawns one without waiting for the next
128
+ * scheduled check.
129
+ */
130
+ export function shouldResume(state, current, now, alive) {
131
+ if (!state.available || !isValidVersion(state.available))
132
+ return false;
133
+ if (compareVersions(state.available, current) <= 0)
134
+ return false;
135
+ if (state.installBlocked || !canSelfUpdate(state.method ?? "unknown"))
136
+ return false;
137
+ if (state.backoffUntil !== undefined && now < state.backoffUntil)
138
+ return false;
139
+ const working = state.workerPid !== undefined &&
140
+ state.workerSince !== undefined &&
141
+ now - state.workerSince < WORKER_MAX_AGE_MS &&
142
+ alive(state.workerPid);
143
+ return !working;
144
+ }
118
145
  /** Whether the background worker should poll the registry now. */
119
146
  export function shouldCheck(state, channel, now) {
120
147
  if (state.backoffUntil !== undefined && now < state.backoffUntil)
@@ -29,6 +29,8 @@ export interface RunDeps {
29
29
  * `mnemom agent` session). Resolving false skips the install this pass.
30
30
  */
31
31
  beforeInstall?(): Promise<boolean>;
32
+ /** The background worker's own pid: it clears its `workerPid` record when done. */
33
+ pid?: number;
32
34
  }
33
35
  export interface RunResult {
34
36
  /** The channel actually honoured (next falls back to latest when not entitled). */
@@ -37,13 +37,17 @@ function failurePatch(state, now, message) {
37
37
  export async function runUpdate(deps, settings, opts = {}) {
38
38
  const now = deps.now();
39
39
  const state = deps.loadState();
40
+ // Every exit path forgets this worker's record, so startup can tell it's gone.
41
+ const done = deps.pid !== undefined && state.workerPid === deps.pid
42
+ ? { workerPid: undefined, workerSince: undefined }
43
+ : {};
40
44
  let tags;
41
45
  try {
42
46
  tags = await deps.fetchDistTags();
43
47
  }
44
48
  catch (err) {
45
49
  const message = err instanceof Error ? err.message : String(err);
46
- deps.saveState(failurePatch(state, now, message));
50
+ deps.saveState({ ...failurePatch(state, now, message), ...done });
47
51
  return { channel: settings.channel, channelDowngraded: false, error: message };
48
52
  }
49
53
  const entitled = settings.channel === "next" ? await resolveEntitlement(deps, state, now) : false;
@@ -71,12 +75,18 @@ export async function runUpdate(deps, settings, opts = {}) {
71
75
  };
72
76
  const result = { channel, channelDowngraded, method, target, action };
73
77
  if (action.kind !== "install") {
74
- deps.saveState(base);
78
+ deps.saveState({ ...base, ...done });
75
79
  return result;
76
80
  }
77
- const proceed = deps.beforeInstall ? await deps.beforeInstall() : true;
78
- if (!proceed) {
81
+ let proceed = true;
82
+ if (deps.beforeInstall) {
83
+ // Record what we found BEFORE the (possibly hours-long) wait: if this worker
84
+ // is killed meanwhile, the next launch still knows an update is pending.
79
85
  deps.saveState(base);
86
+ proceed = await deps.beforeInstall();
87
+ }
88
+ if (!proceed) {
89
+ deps.saveState({ ...base, ...done });
80
90
  return { ...result, action: { kind: "notify", version: action.version, reason: "deferred" } };
81
91
  }
82
92
  const outcome = await deps.install(action.version);
@@ -87,15 +97,20 @@ export async function runUpdate(deps, settings, opts = {}) {
87
97
  installBlocked: undefined,
88
98
  pending: { from: deps.current, to: action.version, channel },
89
99
  followChannel: channel,
100
+ ...done,
90
101
  });
91
102
  return { ...result, installed: action.version };
92
103
  }
93
104
  if (outcome.eacces) {
94
105
  // Retrying can't fix permissions — switch to notify-with-instructions.
95
- deps.saveState({ ...base, installBlocked: true, lastError: outcome.message });
106
+ deps.saveState({ ...base, installBlocked: true, lastError: outcome.message, ...done });
96
107
  }
97
108
  else {
98
- deps.saveState({ ...failurePatch(state, now, outcome.message), available: base.available });
109
+ deps.saveState({
110
+ ...failurePatch(state, now, outcome.message),
111
+ available: base.available,
112
+ ...done,
113
+ });
99
114
  }
100
115
  return { ...result, error: outcome.message };
101
116
  }
@@ -2,7 +2,8 @@
2
2
  * The per-invocation hook, run before commander parses argv. It must cost
3
3
  * ~nothing and never fail the user's command:
4
4
  * 1. print a one-line stderr notice (completed update, or an available one);
5
- * 2. if a check is due, spawn the detached worker and return immediately.
5
+ * 2. if a check is due — or a known update was left uninstalled by a worker
6
+ * that died — spawn the detached worker and return immediately.
6
7
  * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
8
  */
8
9
  import { type UpdateChannel, type UpdateMode, type UpdatePrefs, type UpdateState } from "./decide.js";
@@ -18,8 +19,10 @@ export interface StartupDeps {
18
19
  saveState(patch: Partial<UpdateState>): void;
19
20
  entryPath: string;
20
21
  writeErr(line: string): void;
21
- spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): void;
22
+ /** Returns the worker's pid (undefined if the spawn failed). */
23
+ spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): number | undefined;
22
24
  pid: number;
25
+ alive(pid: number): boolean;
23
26
  }
24
27
  export declare function maybeRunUpdateHook(deps: StartupDeps): void;
25
28
  /** dist/lib/update/startup.js → dist/update-worker.js */
@@ -2,16 +2,18 @@
2
2
  * The per-invocation hook, run before commander parses argv. It must cost
3
3
  * ~nothing and never fail the user's command:
4
4
  * 1. print a one-line stderr notice (completed update, or an available one);
5
- * 2. if a check is due, spawn the detached worker and return immediately.
5
+ * 2. if a check is due — or a known update was left uninstalled by a worker
6
+ * that died — spawn the detached worker and return immediately.
6
7
  * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
8
  */
8
9
  import { spawn } from "node:child_process";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { CLI_VERSION } from "../../version.js";
11
- import { resolveSettings, shouldCheck, startupNotice, } from "./decide.js";
12
+ import { resolveSettings, shouldCheck, shouldResume, startupNotice, } from "./decide.js";
12
13
  import { classifyInstall } from "./install-method.js";
13
14
  import { loadUpdateState, saveUpdateState } from "./state.js";
14
15
  import { entryRealpath, loadUpdatePrefs } from "./system.js";
16
+ import { isAlive } from "./worker.js";
15
17
  /** Commands that manage updates themselves, or must stay side-effect free. */
16
18
  const SKIP_COMMANDS = new Set(["update", "config"]);
17
19
  export function maybeRunUpdateHook(deps) {
@@ -41,11 +43,21 @@ export function maybeRunUpdateHook(deps) {
41
43
  }
42
44
  if (SKIP_COMMANDS.has(deps.argv[0] ?? ""))
43
45
  return;
44
- if (!shouldCheck(state, settings.channel, now))
46
+ const due = shouldCheck(state, settings.channel, now);
47
+ const resume = !due && settings.mode === "auto" && shouldResume(state, deps.current, now, deps.alive);
48
+ if (!due && !resume)
45
49
  return;
46
50
  // Stamp first so a burst of commands doesn't spawn a burst of workers.
47
- deps.saveState({ lastCheck: now });
48
- deps.spawnWorker([deps.entryPath, settings.channel, settings.mode, String(deps.pid)]);
51
+ if (due)
52
+ deps.saveState({ lastCheck: now });
53
+ const pid = deps.spawnWorker([
54
+ deps.entryPath,
55
+ settings.channel,
56
+ settings.mode,
57
+ String(deps.pid),
58
+ ]);
59
+ if (pid !== undefined)
60
+ deps.saveState({ workerPid: pid, workerSince: now });
49
61
  }
50
62
  catch {
51
63
  /* the updater never breaks the user's command */
@@ -75,6 +87,8 @@ export function defaultStartupDeps(argv) {
75
87
  });
76
88
  child.on("error", () => { });
77
89
  child.unref();
90
+ return child.pid;
78
91
  },
92
+ alive: (pid) => isAlive(pid),
79
93
  };
80
94
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { CLI_VERSION } from "../../version.js";
10
10
  import { getApiUrl } from "../config.js";
11
- import { isUpdateChannel, isUpdateMode } from "./decide.js";
11
+ import { isUpdateChannel, isUpdateMode, WORKER_MAX_AGE_MS, } from "./decide.js";
12
12
  import { classifyInstall } from "./install-method.js";
13
13
  import { fetchChannelEntitlement, fetchDistTags } from "./registry.js";
14
14
  import { runUpdate } from "./run.js";
@@ -23,7 +23,7 @@ export function parseWorkerArgs(args) {
23
23
  }
24
24
  export const PARENT_POLL_MS = 5000;
25
25
  /** Give up waiting on a very long session; the next run picks the update up. */
26
- export const PARENT_WAIT_MAX_MS = 12 * 60 * 60 * 1000;
26
+ export const PARENT_WAIT_MAX_MS = WORKER_MAX_AGE_MS;
27
27
  /** `kill(pid, 0)` probes liveness without signalling. EPERM still means alive. */
28
28
  export function isAlive(pid, kill = (p, s) => void process.kill(p, s)) {
29
29
  try {
@@ -60,6 +60,7 @@ export function realRunDeps(entry, credential) {
60
60
  fetchEntitlement: async () => fetchChannelEntitlement(fetch, getApiUrl(), await credential()),
61
61
  installMethod: async () => classifyInstall(entry, await npmGlobalRoot()),
62
62
  install: (version) => npmInstallGlobal(version),
63
+ pid: process.pid,
63
64
  };
64
65
  }
65
66
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.3-next.1",
3
+ "version": "0.17.3-next.2",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {