pi-orchestrator-config 4.5.0 → 4.6.0

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/README.md CHANGED
@@ -65,6 +65,7 @@ Single extension that provides:
65
65
  | `/issue-review` | Review a GitHub issue spec and fix it |
66
66
  | `/release [flags]` | Create GitHub release with changelog and version bumping |
67
67
  | `/review-local [branch]` | Review local uncommitted or branch changes |
68
+ | `/qodo-review [--autofix] [--fast\|--deep] [--ticket <url>] [path ...]` | Review local changes with Qodo, attach session context, and optionally autofix (max 3 cycles) |
68
69
  | `/review-handler [url] [--autorabbit] [--autoqodo]` | Process PR review comments, fix approved items |
69
70
  | `/domain-model [focus area]` | Scan codebase and build/update a CONTEXT.md domain glossary for consistent AI vocabulary |
70
71
  | `/refine-review <url>` | Refine and improve existing PR review comments |
@@ -76,7 +77,7 @@ Single extension that provides:
76
77
  | `/dream` | Run memory consolidation — extract, deduplicate, maintain topic-based memory |
77
78
  | `/remember <what>` | Save a memory for future sessions |
78
79
  | `/dream-auto` | Toggle automatic memory dreaming (every 3h + session end) |
79
- | `/cron add\|list\|list-all\|remove [--persist]` | Schedule recurring tasks. Session jobs end with the session; `--persist` keeps a job across Pi sessions in this project. Add/remove mutations still work when leader election fails closed on an unsupported platform. For example: `/cron add every 2h check for new issues --persist` or `/cron remove persist:<uuid>`. `/cron list` opens an overlay for the selected jobs (or both when omitted). Persistent delivery is local at-least-once: a crash or leader recovery can repeat a run. |
80
+ | `/cron add\|list\|list-all\|remove [--persist]` | Schedule recurring tasks. Session jobs end with the session; `--persist` keeps a job across Pi sessions in this project. Add/remove mutations still work when leader election fails closed on an unsupported platform. For example: `/cron add every 2h check for new issues --persist` or `/cron remove persist:<uuid>`. `/cron list` opens an overlay for the selected jobs (or both when omitted). Persistent delivery is local at-least-once: a crash or leader recovery can repeat a run. Project cron writes use a stable OS `flock` (included in Docker via `util-linux`) across the entire transaction; native installations need `flock` and a shared filesystem that supports advisory locks. A busy or unavailable lock fails closed after two seconds. **Upgrade gate (enforced):** stop and drain every old Pi process writing crons on the shared mount before starting a version with this kernel lock; old directory-lock writers do not honor `flock` and must not resume. If a legacy `crons.json.mutation.lock` directory exists, new writes fail closed even if its PID is invisible or its heartbeat expired. Only after verifying every old writer cannot resume may an operator remove that stale directory (not `crons.json` or cron tasks). Never remove it merely because a write timed out. |
80
81
  | `/async-kill [name\|id\|all]` | Kill async agents (overlay picker or by name/id) |
81
82
  | `/pi-config-settings [project\|global]` | Interactive TUI settings editor — browse all pi-config settings with scope indicators, smart pickers for providers/models, and immediate save |
82
83
  | `/status` | Unified session snapshot — async agents, cron tasks, git branch, context usage |
@@ -466,6 +467,13 @@ settings; it reads the project locally and stores its regenerable graph in `./gr
466
467
  Native installations require Node.js 22+, npm 12+, Python 3, `make`, and G++; the
467
468
  installer uses the same audited strict installation and failure remains non-fatal.
468
469
 
470
+ **Persistent cron locking:** The image includes `flock` from `util-linux` for kernel-backed,
471
+ cross-container store mutations on shared mounts. The shared filesystem must support
472
+ advisory locks. Before deploying this version alongside old Pi containers, stop and
473
+ drain every legacy cron writer. An existing legacy mutation-lock directory blocks
474
+ new writes until an operator verifies old writers cannot resume and removes only
475
+ that directory. Do not stop running containers merely to test this PR.
476
+
469
477
  **CLI provider binaries (optional `cli_agents`):** The image installs the CLIs used by
470
478
  `cli-*` providers — `claude` (Claude Code), `gemini` (`@google/gemini-cli`), and
471
479
  `agent` (Cursor Agent CLI). Enable with `cli_agents` in settings (e.g.
@@ -710,6 +718,7 @@ PI_PIDIFF_ENABLE=false pi
710
718
  | `-v "$HOME/.claude/credentials.json":"$HOME/.claude/credentials.json":ro` | Claude CLI auth (for acpx-claude / cli-claude models) |
711
719
  | `-v "$HOME/.config/glab-cli":"$HOME/.config/glab-cli":ro` | GitLab CLI config (auth tokens, host settings) |
712
720
  | `-v "$HOME/.coderabbit":"$HOME/.coderabbit":rw` | CodeRabbit CLI auth and review data |
721
+ | `-v "$HOME/.qodo":"$HOME/.qodo":rw` | Qodo CLI auth directory (mount at the container user's `$HOME/.qodo`; with `PI_HOST_USER`, that is `/home/$PI_HOST_USER/.qodo`). Create it on the host first with `mkdir -p "$HOME/.qodo"`; inside the container run `qodo read whoami` and, if signed out, `qodo login`. Host OS-keychain credentials may not work inside the container. |
713
722
  | `-v "$HOME/screenshots":"$HOME/screenshots"` | Share screenshots/images with the agent |
714
723
  | `-v /var/run/docker.sock:/var/run/docker.sock:ro` + `--group-add $(stat -c '%g' /var/run/docker.sock)` | Docker container inspection via `docker-safe` |
715
724
  | `-v /var/run/podman/podman.sock:/var/run/podman/podman.sock:ro` | Podman container inspection via `docker-safe` |
@@ -740,6 +749,7 @@ PI_PIDIFF_ENABLE=false pi
740
749
  | `jq` | JSON processing |
741
750
  | `rg` (ripgrep) | Fast recursive search |
742
751
  | `cr` | CodeRabbit CLI — local AI code reviews |
752
+ | `qodo` | Qodo CLI — local code reviews (`qodo --version`; mount `~/.qodo` as above and authenticate inside the container if needed) |
743
753
  | `curl` | HTTP requests |
744
754
 
745
755
  #### `myk-pi-tools` subcommands
@@ -1,19 +1,17 @@
1
- import type { AuthResult, Model, Provider } from "@earendil-works/pi-ai";
1
+ import type { AuthResult, Model, Provider, RefreshModelsContext } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { createRuntimeProvider } from "../shared/create-runtime-provider.js";
4
3
  import { createLogger } from "../shared/logger.js";
5
4
  import {
6
- OpenAiCompatibleDiscoveryCache,
7
- buildLiteLlmCapabilitiesUrl,
5
+ buildOpenAiCompatibleCapabilitiesUrl,
8
6
  buildOpenAiCompatibleModelsRequest,
9
- enrichLiteLlmReasoning,
7
+ enrichOpenAiCompatibleReasoning,
10
8
  findEligibleOpenAiCompatibleProviderConfigsResult,
11
9
  formatOpenAiCompatibleDiscoverySummary,
12
10
  materializeOpenAiCompatibleModels,
13
11
  openAiCompatibleConnectionFingerprint,
14
12
  redactOpenAiCompatibleDiagnostic,
15
13
  resolveStaticOpenAiCompatibleHeaders,
16
- type LiteLlmCapabilityRecord,
14
+ type OpenAiCompatibleCapabilityRecord,
17
15
  type OpenAiCompatibleModelRecord,
18
16
  type ResolvedOpenAiCompatibleConnection,
19
17
  } from "../shared/openai-compatible-discovery.js";
@@ -31,16 +29,14 @@ function responseRecords(payload: unknown): OpenAiCompatibleModelRecord[] {
31
29
  });
32
30
  }
33
31
 
34
- function capabilityRecords(payload: unknown): LiteLlmCapabilityRecord[] {
32
+ function capabilityRecords(payload: unknown): OpenAiCompatibleCapabilityRecord[] {
35
33
  if (!payload || typeof payload !== "object" || !Array.isArray((payload as { data?: unknown }).data))
36
- throw new Error("LiteLLM capability response must contain a data array");
37
- return (payload as { data: unknown[] }).data.filter(
38
- (record): record is LiteLlmCapabilityRecord => Boolean(record) && typeof record === "object" && !Array.isArray(record),
34
+ throw new Error("OpenAI-compatible capability response must contain a data array");
35
+ const records = (payload as { data: unknown[] }).data.filter(
36
+ (record): record is OpenAiCompatibleCapabilityRecord => Boolean(record) && typeof record === "object" && !Array.isArray(record),
39
37
  );
40
- }
41
-
42
- function isLiteLlmProvider(sourceProviderId: string, source: Provider): boolean {
43
- return `${sourceProviderId} ${source.name ?? ""}`.toLowerCase().includes("litellm");
38
+ log.debug("parsed capability records", { count: records.length });
39
+ return records;
44
40
  }
45
41
 
46
42
  function resolvedConnection(source: Provider, auth?: AuthResult, staticHeaders?: Record<string, string>): ResolvedOpenAiCompatibleConnection {
@@ -58,100 +54,86 @@ function combineModels(sourceProviderId: string, staticModels: readonly Model[],
58
54
  return [...staticModels, ...discovered];
59
55
  }
60
56
 
61
- async function discoverProvider(
57
+ async function refreshProviderModels(
62
58
  pi: ExtensionAPI,
63
59
  ctx: any,
64
60
  sourceProviderId: string,
65
- rawStaticHeaders: Record<string, string> | undefined,
66
- cache: OpenAiCompatibleDiscoveryCache<OpenAiCompatibleModelRecord>,
67
- signal: AbortSignal,
68
- isCurrent: () => boolean,
69
- registeredSources: Set<string>,
70
- ): Promise<void> {
71
- if (!isCurrent()) return;
72
- const source = ctx.modelRegistry.getProvider(sourceProviderId) as Provider | undefined;
73
- if (!source) {
74
- log.warn(`${sourceProviderId}: configured provider was not resolved`);
75
- return;
76
- }
77
- let auth: AuthResult | undefined;
78
- let staticHeaders: Record<string, string> | undefined;
79
- try {
80
- auth = await ctx.modelRegistry.getProviderAuth(sourceProviderId);
81
- staticHeaders = await resolveStaticOpenAiCompatibleHeaders(rawStaticHeaders);
82
- } catch {
83
- log.warn(`${sourceProviderId}: authentication or configured headers could not be resolved`);
84
- return;
85
- }
86
- const connection = resolvedConnection(source, auth, staticHeaders);
87
- let request;
61
+ source: Provider,
62
+ staticModels: readonly Model[],
63
+ refresh: RefreshModelsContext,
64
+ appendSummary: () => boolean,
65
+ discoverModelCapabilities: boolean,
66
+ rawStaticHeaders?: Record<string, string>,
67
+ snapshot: { discovered: Model[]; fingerprint?: string; generation: number },
68
+ lifecycle: { signal: AbortSignal; isCurrent: () => boolean },
69
+ ): Promise<Model[]> {
70
+ const generation = ++snapshot.generation;
71
+ const active = () => {
72
+ const current = lifecycle.isCurrent();
73
+ log.debug("discovery refresh eligibility", { provider: sourceProviderId, generation, current, aborted: refresh.signal.aborted, latest: generation === snapshot.generation });
74
+ return current && !refresh.signal.aborted && generation === snapshot.generation;
75
+ };
76
+ let connection: ResolvedOpenAiCompatibleConnection = { baseUrl: source.baseUrl };
77
+ let scopeResolved = false;
88
78
  try {
89
- request = buildOpenAiCompatibleModelsRequest(connection);
90
- } catch (error) {
91
- log.warn(`${sourceProviderId}: discovery inactive`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
92
- return;
93
- }
94
- const records = await cache.get(async () => {
95
- try {
96
- const response = await fetch(request.url, {
97
- headers: request.headers,
98
- redirect: "error",
99
- signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
100
- });
101
- if (!response.ok) throw new Error(`OpenAI-compatible /v1/models returned HTTP ${response.status}`);
102
- const records = responseRecords(await response.json());
103
- if (!isLiteLlmProvider(sourceProviderId, source)) return records;
79
+ const auth = await ctx.modelRegistry.getProviderAuth(sourceProviderId) as AuthResult | undefined;
80
+ const staticHeaders = await resolveStaticOpenAiCompatibleHeaders(rawStaticHeaders);
81
+ connection = resolvedConnection(ctx.modelRegistry.getProvider(sourceProviderId) ?? source, auth, staticHeaders);
82
+ if (!active()) return [...staticModels];
83
+ const fingerprint = openAiCompatibleConnectionFingerprint(connection);
84
+ if (snapshot.fingerprint !== fingerprint) {
85
+ snapshot.discovered = [];
86
+ snapshot.fingerprint = fingerprint;
87
+ log.debug("discovery connection scope changed", { provider: sourceProviderId });
88
+ }
89
+ scopeResolved = true;
90
+ if (!refresh.allowNetwork) return [...staticModels, ...snapshot.discovered];
91
+ const request = buildOpenAiCompatibleModelsRequest(connection);
92
+ const response = await fetch(request.url, {
93
+ headers: request.headers, redirect: "error",
94
+ signal: AbortSignal.any([refresh.signal, lifecycle.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
95
+ });
96
+ if (!response.ok) throw new Error(`OpenAI-compatible /v1/models returned HTTP ${response.status}`);
97
+ let records = responseRecords(await response.json());
98
+
99
+ if (discoverModelCapabilities) {
104
100
  try {
105
- const capabilityResponse = await fetch(buildLiteLlmCapabilitiesUrl(request.url), {
101
+ const capabilityResponse = await fetch(buildOpenAiCompatibleCapabilitiesUrl(request.url), {
106
102
  headers: request.headers,
107
103
  redirect: "error",
108
- signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
104
+ signal: AbortSignal.any([refresh.signal, lifecycle.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
109
105
  });
110
- if (!capabilityResponse.ok) throw new Error(`LiteLLM capability endpoint returned HTTP ${capabilityResponse.status}`);
111
- return enrichLiteLlmReasoning(records, capabilityRecords(await capabilityResponse.json()));
106
+ if (!capabilityResponse.ok) throw new Error(`OpenAI-compatible capability endpoint returned HTTP ${capabilityResponse.status}`);
107
+ records = enrichOpenAiCompatibleReasoning(records, capabilityRecords(await capabilityResponse.json()));
112
108
  } catch (error) {
109
+ if (!active()) return [...staticModels];
113
110
  log.warn(`${sourceProviderId}: reasoning capability enrichment unavailable`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
114
- return records;
115
111
  }
116
- } catch (error) {
117
- log.warn(`${sourceProviderId}: discovery refresh failed`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
118
- throw error;
119
112
  }
120
- }, { cacheKey: openAiCompatibleConnectionFingerprint(connection) });
121
- if (!isCurrent() || !records.hasSnapshot) return;
122
- if (records.stale) log.warn(`${sourceProviderId}: refresh failed; using last-known-good discovery result`);
123
113
 
124
- const staticModels = (ctx.modelRegistry.getAll() as Model[])
125
- .filter((model) => model.provider === sourceProviderId);
126
- const models = combineModels(sourceProviderId, staticModels, records.models, request.streamBaseUrl);
127
- try {
128
- const provider = await createRuntimeProvider({
129
- id: sourceProviderId,
130
- name: source.name,
131
- baseUrl: request.streamBaseUrl,
132
- auth: source.auth,
133
- models,
134
- api: {
135
- stream: source.stream.bind(source),
136
- streamSimple: source.streamSimple.bind(source),
137
- },
138
- });
139
- if (!isCurrent()) return;
140
- // Pi composes this native provider with the existing models.json entry. The
141
- // source key is therefore both the ordinary picker label and routing key.
142
- pi.registerProvider(provider);
143
- registeredSources.add(sourceProviderId);
144
- try {
145
- pi.appendEntry<OpenAiCompatibleDiscoverySummary>(
146
- "openai-compatible-discovery-summary",
147
- { summary: formatOpenAiCompatibleDiscoverySummary(sourceProviderId, records.models.length) },
148
- );
149
- } catch (error) {
150
- log.warn(`${sourceProviderId}: discovery summary append failed`, error instanceof Error ? error.name : typeof error);
114
+ if (!active()) return [...staticModels];
115
+ const models = combineModels(sourceProviderId, staticModels, records, request.streamBaseUrl);
116
+ snapshot.discovered = models.slice(staticModels.length);
117
+ if (active() && appendSummary()) {
118
+ try {
119
+ pi.appendEntry<OpenAiCompatibleDiscoverySummary>(
120
+ "openai-compatible-discovery-summary",
121
+ { summary: formatOpenAiCompatibleDiscoverySummary(sourceProviderId, records.length) },
122
+ );
123
+ } catch (error) {
124
+ log.warn(`${sourceProviderId}: discovery summary append failed`, error instanceof Error ? error.name : typeof error);
125
+ }
151
126
  }
152
- log.info(`${sourceProviderId}: registered ${records.models.length} discovered model(s) on configured provider`);
127
+ log.info(`${sourceProviderId}: refreshed ${records.length} discovered model(s) on configured provider`);
128
+ return models;
153
129
  } catch (error) {
154
- log.warn(`${sourceProviderId}: provider augmentation failed`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
130
+ if (active())
131
+ log.warn(`${sourceProviderId}: discovery refresh failed`, scopeResolved
132
+ ? redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection)
133
+ : { phase: "connection resolution", cause: error instanceof Error ? error.name : typeof error });
134
+ if (!active()) return [...staticModels];
135
+ if (!scopeResolved) { snapshot.discovered = []; snapshot.fingerprint = undefined; }
136
+ return [...staticModels, ...snapshot.discovered];
155
137
  }
156
138
  }
157
139
 
@@ -176,16 +158,14 @@ export default function (pi: ExtensionAPI) {
176
158
  );
177
159
  }
178
160
 
179
- const cache = new OpenAiCompatibleDiscoveryCache<OpenAiCompatibleModelRecord>();
180
161
  const registeredSources = new Set<string>();
181
- const controllers = new Set<AbortController>();
182
- let active = true;
183
- let generation = 0;
184
-
162
+ let lifecycleGeneration = 0;
163
+ let lifecycleController = new AbortController();
185
164
  const restoreSources = () => {
165
+ lifecycleController.abort();
166
+ lifecycleGeneration++;
186
167
  for (const id of registeredSources) {
187
168
  try {
188
- // Removing our native overlay makes Pi recompose the unchanged static provider.
189
169
  pi.unregisterProvider(id);
190
170
  } catch (error) {
191
171
  log.warn("provider augmentation cleanup failed", { provider: id, cause: error instanceof Error ? error.name : typeof error });
@@ -194,39 +174,68 @@ export default function (pi: ExtensionAPI) {
194
174
  }
195
175
  };
196
176
 
197
- pi.on("session_shutdown", () => {
198
- active = false;
199
- generation += 1;
200
- for (const controller of controllers) controller.abort();
201
- controllers.clear();
202
- cache.clear();
203
- restoreSources();
204
- });
177
+ pi.on("session_shutdown", restoreSources);
205
178
 
206
179
  pi.on("session_start", async (_event, ctx) => {
207
- if (ctx?.mode !== "tui" || ctx?.hasUI !== true) return;
208
- active = true;
209
- generation += 1;
210
- for (const controller of controllers) controller.abort();
211
- controllers.clear();
212
- cache.clear();
213
180
  restoreSources();
214
- const currentGeneration = generation;
215
- const controller = new AbortController();
216
- controllers.add(controller);
217
- const isCurrent = () => active && currentGeneration === generation;
181
+ lifecycleController = new AbortController();
182
+ const generation = lifecycleGeneration;
183
+ const lifecycle = {
184
+ signal: lifecycleController.signal,
185
+ isCurrent: () => {
186
+ const current = generation === lifecycleGeneration;
187
+ log.debug("discovery lifecycle eligibility", { generation, lifecycleGeneration, current });
188
+ return current;
189
+ },
190
+ };
218
191
  const configResult = findEligibleOpenAiCompatibleProviderConfigsResult();
219
192
  if (configResult.providers.length === 0) {
220
193
  const diagnostic = { modelsConfig: configResult.status };
221
194
  if (configResult.status === "unreadable" || configResult.status === "malformed")
222
195
  log.warn("inactive: OpenAI-compatible discovery configuration unavailable", diagnostic);
223
196
  else log.debug("inactive: no opted-in OpenAI-compatible providers", diagnostic);
224
- controllers.delete(controller);
225
197
  return;
226
198
  }
227
- await Promise.all(configResult.providers.map(({ id, headers }) =>
228
- discoverProvider(pi, ctx, id, headers, cache, controller.signal, isCurrent, registeredSources),
229
- ));
230
- controllers.delete(controller);
199
+
200
+ let startupRefresh = ctx?.mode === "tui" && ctx?.hasUI === true;
201
+ const registrationRefreshes: Promise<void>[] = [];
202
+ const activeProviders: string[] = [];
203
+ for (const { id, headers, discoverModelCapabilities } of configResult.providers) {
204
+ const source = ctx.modelRegistry.getProvider(id) as Provider | undefined;
205
+ if (!source) {
206
+ log.warn(`${id}: configured provider was not resolved`);
207
+ continue;
208
+ }
209
+ const staticModels = (ctx.modelRegistry.getAll() as Model[])
210
+ .filter((model) => model.provider === id);
211
+ const snapshot = { discovered: [] as Model[], fingerprint: undefined as string | undefined, generation: 0 };
212
+ // Registration starts an unawaited offline refresh. Its first callback signals
213
+ // that it has acquired its generation, so the network pass can safely supersede it.
214
+ let registrationStarted!: () => void;
215
+ registrationRefreshes.push(new Promise<void>((resolve) => { registrationStarted = resolve; }));
216
+ pi.registerProvider(id, {
217
+ refreshModels: (refresh) => {
218
+ log.debug("provider discovery refresh requested", { provider: id, generation: snapshot.generation + 1, allowNetwork: refresh.allowNetwork, aborted: refresh.signal.aborted });
219
+ registrationStarted();
220
+ return refreshProviderModels(
221
+ pi, ctx, id, source, staticModels, refresh, () => startupRefresh,
222
+ discoverModelCapabilities, headers, snapshot, lifecycle,
223
+ );
224
+ },
225
+ });
226
+ registeredSources.add(id);
227
+ activeProviders.push(id);
228
+ }
229
+ try {
230
+ await Promise.all(registrationRefreshes);
231
+ log.debug("registration offline refreshes started", { providers: activeProviders });
232
+ await ctx.modelRegistry.refresh({
233
+ providers: activeProviders,
234
+ allowNetwork: true,
235
+ force: true,
236
+ });
237
+ } finally {
238
+ startupRefresh = false;
239
+ }
231
240
  });
232
241
  }
@@ -1,7 +1,7 @@
1
1
  /** Durable cron storage and local-filesystem leader locks. Delivery is at-least-once. */
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { execFileSync } from "node:child_process";
4
+ import { execFileSync, spawnSync } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { parseProcStartTime } from "./utils.js";
7
7
  import { createLogger } from "../shared/logger.js";
@@ -21,7 +21,7 @@ export interface DurableCronTask {
21
21
  nextRun?: number;
22
22
  }
23
23
  export interface DurableCronEnvelope { version: 1; tasks: DurableCronTask[]; }
24
- export interface CronLockOwner { pid: number; process_start_token: string; instance_id: string; heartbeat_at: string; fd?: number; }
24
+ export interface CronLockOwner { pid: number; process_start_token: string; instance_id: string; heartbeat_at: string; pid_namespace?: string; fd?: number; }
25
25
 
26
26
  type StartTokenDeps = { readFileSync: (path: string, encoding: BufferEncoding) => string; execFileSync: (file: string, args: readonly string[], options: { encoding: BufferEncoding; windowsHide: boolean }) => string; platform: NodeJS.Platform };
27
27
  const startTokenDeps: StartTokenDeps = { readFileSync: fs.readFileSync, execFileSync, platform: process.platform };
@@ -52,18 +52,45 @@ function ownerPath(dir: string) { return path.join(dir, "owner.json"); }
52
52
  /** True only when the OS can prove this record cannot still own the lock. */
53
53
  function ownerIsProvenDead(owner: CronLockOwner): boolean {
54
54
  if (!owner?.pid || !Number.isInteger(owner.pid) || owner.pid < 1) return false;
55
+ // A PID from another namespace (or a legacy record without namespace) is
56
+ // not evidence of death, even when kill(pid, 0) reports ESRCH.
57
+ const namespace = processPidNamespace();
58
+ if (namespace ? !owner.pid_namespace || owner.pid_namespace !== namespace : owner.pid_namespace?.startsWith("pid:[")) {
59
+ log.debug("cron_lock_owner_foreign", { pid: owner.pid, instance_id: owner.instance_id, namespace: owner.pid_namespace });
60
+ return false;
61
+ }
55
62
  try {
56
63
  process.kill(owner.pid, 0);
57
64
  } catch (error: any) {
58
65
  // EPERM means the process exists but is not signalable; all other failures
59
66
  // are treated conservatively except the OS's definitive "no such process".
60
- return error?.code === "ESRCH";
67
+ const dead = error?.code === "ESRCH";
68
+ log.debug("cron_lock_owner_pid_check", { pid: owner.pid, instance_id: owner.instance_id, dead, reason: error?.code });
69
+ return dead;
61
70
  }
62
71
  if (!owner.process_start_token || owner.process_start_token === "unknown") return false;
63
72
  const token = resolveProcessStartToken(owner.pid);
64
73
  // An unavailable identity source fails closed. A different issued token
65
74
  // proves PID reuse, so this record belongs to a dead process.
66
- return token !== null && token !== owner.process_start_token;
75
+ const dead = token !== null && token !== owner.process_start_token;
76
+ log.debug("cron_lock_owner_token_check", { pid: owner.pid, instance_id: owner.instance_id, dead });
77
+ return dead;
78
+ }
79
+ function processPidNamespace(): string | null {
80
+ try { return fs.readlinkSync("/proc/self/ns/pid"); }
81
+ catch (error: any) { log.debug("cron_pid_namespace_unavailable", { pid: process.pid, reason: error?.code || "read_error" }); return null; }
82
+ }
83
+ function leaseIsExpired(owner: CronLockOwner): boolean {
84
+ const ageMs = Date.now() - Date.parse(owner.heartbeat_at);
85
+ const expired = Number.isFinite(ageMs) && ageMs >= STALE_LOCK_MS;
86
+ log.debug("cron_lock_lease_age", { instance_id: owner.instance_id, age_ms: ageMs, expired });
87
+ return expired;
88
+ }
89
+ function canReclaim(owner: CronLockOwner): boolean {
90
+ const dead = ownerIsProvenDead(owner);
91
+ const expired = leaseIsExpired(owner);
92
+ log.debug("cron_lock_reclaim_decision", { instance_id: owner.instance_id, dead, expired });
93
+ return dead || expired;
67
94
  }
68
95
  function readOwner(dir: string): CronLockOwner | null {
69
96
  try {
@@ -74,7 +101,9 @@ function readOwner(dir: string): CronLockOwner | null {
74
101
  } catch (error: any) { log.debug("cron_lock_owner_unreadable", { dir, reason: error?.code || "parse_error" }); return null; }
75
102
  }
76
103
  function sameOwner(a: CronLockOwner | null, b: CronLockOwner) {
77
- return !!a && a.instance_id === b.instance_id && a.pid === b.pid && a.process_start_token === b.process_start_token;
104
+ const same = !!a && a.instance_id === b.instance_id && a.pid === b.pid && a.process_start_token === b.process_start_token && a.pid_namespace === b.pid_namespace;
105
+ log.debug("cron_lock_owner_compare", { instance_id: b.instance_id, current_instance_id: a?.instance_id, same });
106
+ return same;
78
107
  }
79
108
  function serializedOwner(owner: CronLockOwner) {
80
109
  const { fd: _fd, ...record } = owner;
@@ -83,24 +112,30 @@ function serializedOwner(owner: CronLockOwner) {
83
112
  function claimIsOld(claim: string) {
84
113
  try {
85
114
  const ageMs = Date.now() - fs.statSync(claim).mtimeMs;
86
- const old = ageMs >= STALE_LOCK_MS;
87
- log.debug("cron_lock_claim_age", { claim, age_ms: ageMs, old });
115
+ const file = ownerPath(claim);
116
+ const ownerAgeMs = fs.existsSync(file) ? Date.now() - fs.statSync(file).mtimeMs : ageMs;
117
+ const old = ageMs >= STALE_LOCK_MS && ownerAgeMs >= STALE_LOCK_MS;
118
+ log.debug("cron_lock_claim_age", { claim, age_ms: ageMs, owner_age_ms: ownerAgeMs, old });
88
119
  return old;
89
120
  } catch (error: any) { log.warn("cron_lock_claim_stat_failed", { claim, reason: error?.code || "stat_error" }); return false; }
90
121
  }
91
122
  function restoreClaim(claim: string, dir: string) {
123
+ if (fs.existsSync(dir)) {
124
+ log.warn("cron_lock_claim_restore_failed", { claim, dir, reason: "canonical_lock_exists" });
125
+ return;
126
+ }
92
127
  try { fs.renameSync(claim, dir); } catch { log.warn("cron_lock_claim_restore_failed", { claim, dir }); }
93
128
  }
94
129
  /** Read the canonical record first; detach and delete only that proven-stale record. */
95
- function reclaimStaleLock(dir: string, instanceId: string, kind: "leader" | "mutation"): boolean {
130
+ function reclaimStaleLock(dir: string, instanceId: string): boolean {
96
131
  const expected = readOwner(dir);
97
- if (!(expected ? ownerIsProvenDead(expected) : claimIsOld(dir))) return false;
132
+ if (!(expected ? canReclaim(expected) : claimIsOld(dir))) return false;
98
133
  const claim = `${dir}.claim-${randomUUID()}`;
99
134
  try { fs.renameSync(dir, claim); } catch { return false; }
100
135
  const current = readOwner(claim);
101
- if (expected ? !sameOwner(current, expected) : current || !claimIsOld(claim)) { restoreClaim(claim, dir); return false; }
102
- try { fs.rmSync(claim, { recursive: true, force: false }); log.info(`cron_${kind}_lock_reclaim`, { dir, instance_id: instanceId, outcome: "reclaimed" }); return true; }
103
- catch (error: any) { log.warn(`cron_${kind}_lock_reclaim`, { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "remove_error" }); restoreClaim(claim, dir); return false; }
136
+ if (expected ? !sameOwner(current, expected) || !canReclaim(current!) : current || !claimIsOld(claim)) { restoreClaim(claim, dir); return false; }
137
+ try { fs.rmSync(claim, { recursive: true, force: false }); log.info("cron_leader_lock_reclaim", { dir, instance_id: instanceId, outcome: "reclaimed" }); return true; }
138
+ catch (error: any) { log.warn("cron_leader_lock_reclaim", { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "remove_error" }); restoreClaim(claim, dir); return false; }
104
139
  }
105
140
  function safelyRemoveLock(dir: string, expected?: CronLockOwner): boolean {
106
141
  if (expected && !sameOwner(readOwner(dir), expected)) { log.debug("cron_lock_remove", { dir, outcome: "not_owner" }); return false; }
@@ -114,7 +149,7 @@ function acquireDirectoryLock(dir: string, instanceId: string, reclaimStale: boo
114
149
  const token = resolveProcessStartToken(process.pid, deps);
115
150
  // Without a PID-reuse-safe token, durable leadership is disabled rather than unsafe.
116
151
  if (!token) { log.warn("cron_leader_lock_unavailable", { dir, instance_id: instanceId, reason: "no_process_token" }); return null; }
117
- const owner: CronLockOwner = { pid: process.pid, process_start_token: token, instance_id: instanceId, heartbeat_at: new Date().toISOString() };
152
+ const owner: CronLockOwner = { pid: process.pid, process_start_token: token, instance_id: instanceId, heartbeat_at: new Date().toISOString(), pid_namespace: processPidNamespace() || undefined };
118
153
  try {
119
154
  fs.mkdirSync(dir, { mode: 0o700 });
120
155
  owner.fd = fs.openSync(ownerPath(dir), "wx", 0o600);
@@ -128,7 +163,7 @@ function acquireDirectoryLock(dir: string, instanceId: string, reclaimStale: boo
128
163
  } catch (error: any) {
129
164
  if (owner.fd !== undefined) try { fs.closeSync(owner.fd); } catch {}
130
165
  if (error?.code !== "EEXIST" || !reclaimStale) { (error?.code === "EEXIST" ? log.debug : log.warn)("cron_leader_lock_acquire", { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "lock_error" }); return null; }
131
- if (!reclaimStaleLock(dir, instanceId, "leader")) return null;
166
+ if (!reclaimStaleLock(dir, instanceId)) return null;
132
167
  return acquireDirectoryLock(dir, instanceId, false);
133
168
  }
134
169
  }
@@ -156,46 +191,66 @@ function atomicWrite(file: string, content: string) {
156
191
  fs.writeFileSync(temp, content, { mode: 0o600 });
157
192
  fs.renameSync(temp, file);
158
193
  }
159
- function acquireMutationLock(dir: string, instanceId = randomUUID()): CronLockOwner | null {
160
- const token = resolveProcessStartToken(process.pid);
161
- if (!token) { log.warn("cron_mutation_lock_unavailable", { dir, instance_id: instanceId, reason: "no_process_token" }); return null; }
162
- const owner: CronLockOwner = { pid: process.pid, process_start_token: token, instance_id: instanceId, heartbeat_at: new Date().toISOString() };
163
- try {
164
- fs.mkdirSync(dir, { mode: 0o700 });
165
- fs.writeFileSync(ownerPath(dir), serializedOwner(owner), { flag: "wx", mode: 0o600 });
166
- const acquired = sameOwner(readOwner(dir), owner);
167
- log.debug("cron_mutation_lock_acquire", { dir, instance_id: instanceId, outcome: acquired ? "acquired" : "verification_failed" });
168
- return acquired ? owner : null;
169
- } catch (error: any) {
170
- if (error?.code !== "EEXIST") { log.warn("cron_mutation_lock_acquire", { dir, instance_id: instanceId, outcome: "failed", reason: error?.code || "lock_error" }); return null; }
171
- if (!reclaimStaleLock(dir, instanceId, "mutation")) return null;
172
- return acquireMutationLock(dir, instanceId);
194
+ function acquireKernelMutationLock(store: string): number {
195
+ fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
196
+ const lock = `${store}.mutation.flock`;
197
+ const fd = fs.openSync(lock, "a", 0o600);
198
+ // flock(1) locks the inherited open file description. The parent keeps its
199
+ // fd open across the whole synchronous transaction; close releases it even
200
+ // after the short-lived flock process exits. Never replace this lock inode.
201
+ const result = spawnSync("flock", ["-x", "-w", "2", "3"], { stdio: ["ignore", "ignore", "pipe", fd], encoding: "utf8" });
202
+ if (result.status !== 0) {
203
+ fs.closeSync(fd);
204
+ log.error("cron_mutation_kernel_lock", { lock, outcome: "failed", reason: result.error?.message || result.stderr });
205
+ throw new Error("Timed out waiting for cron storage lock");
206
+ }
207
+ // A previous pi version may still hold its independent directory lock.
208
+ // Never infer death from an invisible PID or an old heartbeat: only the
209
+ // operator can confirm all legacy writers have stopped and remove it.
210
+ if (fs.existsSync(lockPath(store, "mutation"))) {
211
+ fs.closeSync(fd);
212
+ log.error("cron_mutation_legacy_lock", { store, outcome: "blocked_until_verified_migration" });
213
+ throw new Error("legacy cron storage lock present; stop all old cron writers and verify they cannot resume before removing the stale mutation-lock directory");
173
214
  }
215
+ log.debug("cron_mutation_kernel_lock", { lock, outcome: "acquired" });
216
+ return fd;
174
217
  }
175
- function releaseMutationLock(dir: string, owner: CronLockOwner) { safelyRemoveLock(dir, owner); }
176
218
  export function mutateDurableCronStore(store: string, change: (tasks: DurableCronTask[]) => DurableCronTask[]): DurableCronEnvelope {
177
- const lock = lockPath(store, "mutation"); let owner: CronLockOwner | null = null;
178
- for (let attempt = 0; attempt < 100; attempt++) { owner = acquireMutationLock(lock); if (owner) break; sleep(20); }
179
- if (!owner) throw new Error("Timed out waiting for cron storage lock");
219
+ const kernel = acquireKernelMutationLock(store);
180
220
  try {
181
221
  // Corrupt records must not prevent valid tasks from being updated. Schedulers
182
222
  // log each skipped record before calling here.
183
223
  const current = readDurableCronStore(store).tasks.filter((task) => { try { validateDurableCronTask(task); return true; } catch { return false; } });
184
- const tasks = change(current); validate(tasks); const envelope: DurableCronEnvelope = { version: 1, tasks }; atomicWrite(store, JSON.stringify(envelope)); return envelope;
224
+ const tasks = change(current); validate(tasks);
225
+ const envelope: DurableCronEnvelope = { version: 1, tasks }; atomicWrite(store, JSON.stringify(envelope)); return envelope;
226
+ }
227
+ finally {
228
+ fs.closeSync(kernel);
229
+ log.debug("cron_mutation_kernel_lock", { store, outcome: "released" });
185
230
  }
186
- finally { releaseMutationLock(lock, owner); }
187
231
  }
188
232
  export function acquireLeaderLock(store: string, instanceId: string, deps: StartTokenDeps = startTokenDeps): CronLockOwner | null { return acquireDirectoryLock(lockPath(store, "leader"), instanceId, true, deps); }
233
+ export function isLeaderLockCurrent(store: string, owner: CronLockOwner): boolean {
234
+ const current = readOwner(lockPath(store, "leader"));
235
+ const valid = !!current && sameOwner(current, owner) && !leaseIsExpired(current);
236
+ log.debug("cron_leader_lock_current", { store, instance_id: owner.instance_id, valid });
237
+ return valid;
238
+ }
189
239
  export function refreshLeaderLock(store: string, owner: CronLockOwner): boolean {
190
240
  const dir = lockPath(store, "leader");
191
- if (owner.fd === undefined || !sameOwner(readOwner(dir), owner)) return false;
241
+ if (owner.fd === undefined || !isLeaderLockCurrent(store, owner)) {
242
+ log.warn("cron_leader_lock_refresh", { dir, instance_id: owner.instance_id, outcome: "not_current" });
243
+ return false;
244
+ }
192
245
  owner.heartbeat_at = new Date().toISOString();
193
246
  try {
194
247
  // Write through the exclusively-created inode, never a lock pathname that
195
248
  // could have been atomically reclaimed and recreated by another process.
196
249
  fs.ftruncateSync(owner.fd, 0); fs.writeSync(owner.fd, serializedOwner(owner), 0, "utf8"); fs.fsyncSync(owner.fd);
197
- return sameOwner(readOwner(dir), owner);
198
- } catch { return false; }
250
+ const renewed = sameOwner(readOwner(dir), owner);
251
+ log.debug("cron_leader_lock_refresh", { dir, instance_id: owner.instance_id, renewed });
252
+ return renewed;
253
+ } catch (error: any) { log.warn("cron_leader_lock_refresh", { dir, instance_id: owner.instance_id, reason: error?.code || "write_error" }); return false; }
199
254
  }
200
255
  export function releaseLeaderLock(store: string, owner: CronLockOwner) {
201
256
  const dir = lockPath(store, "leader");