pi-orchestrator-config 4.5.0 → 4.6.1
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 +13 -1
- package/extensions/openai-compatible-discovery/index.ts +131 -122
- package/extensions/orchestrator/cron-store.ts +92 -37
- package/extensions/orchestrator/cron.ts +27 -6
- package/extensions/orchestrator/extended-autocomplete.ts +30 -1
- package/extensions/pidash/pidash.ts +3 -2
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/providers/index.ts +4 -4
- package/extensions/providers/last-thinking-level.ts +336 -70
- package/extensions/shared/create-runtime-provider.ts +6 -0
- package/extensions/shared/openai-compatible-discovery.ts +17 -7
- package/package.json +1 -1
- package/prompts/qodo-review.md +93 -0
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
|
|
@@ -904,6 +914,8 @@ A standalone HTTP sidecar wrapping the Pi SDK, living under `packages/pi-sidecar
|
|
|
904
914
|
|
|
905
915
|
CLI commands: `npx pi-sidecar` (start server), `npx pi-sidecar-start` (background start/stop).
|
|
906
916
|
|
|
917
|
+
`GET /providers` lists registered providers and whether each supports a session API key, even when no models or credentials are available. The Python client exposes this as `await SidecarClient.get_providers()`.
|
|
918
|
+
|
|
907
919
|
See [`packages/pi-sidecar/README.md`](packages/pi-sidecar/README.md) for full documentation.
|
|
908
920
|
See [`packages/pi-sidecar/CONSUMER-GUIDE.md`](packages/pi-sidecar/CONSUMER-GUIDE.md) for integration best practices.
|
|
909
921
|
|
|
@@ -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
|
-
|
|
7
|
-
buildLiteLlmCapabilitiesUrl,
|
|
5
|
+
buildOpenAiCompatibleCapabilitiesUrl,
|
|
8
6
|
buildOpenAiCompatibleModelsRequest,
|
|
9
|
-
|
|
7
|
+
enrichOpenAiCompatibleReasoning,
|
|
10
8
|
findEligibleOpenAiCompatibleProviderConfigsResult,
|
|
11
9
|
formatOpenAiCompatibleDiscoverySummary,
|
|
12
10
|
materializeOpenAiCompatibleModels,
|
|
13
11
|
openAiCompatibleConnectionFingerprint,
|
|
14
12
|
redactOpenAiCompatibleDiagnostic,
|
|
15
13
|
resolveStaticOpenAiCompatibleHeaders,
|
|
16
|
-
type
|
|
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):
|
|
32
|
+
function capabilityRecords(payload: unknown): OpenAiCompatibleCapabilityRecord[] {
|
|
35
33
|
if (!payload || typeof payload !== "object" || !Array.isArray((payload as { data?: unknown }).data))
|
|
36
|
-
throw new Error("
|
|
37
|
-
|
|
38
|
-
(record): record is
|
|
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
|
|
57
|
+
async function refreshProviderModels(
|
|
62
58
|
pi: ExtensionAPI,
|
|
63
59
|
ctx: any,
|
|
64
60
|
sourceProviderId: string,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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(`
|
|
111
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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}:
|
|
127
|
+
log.info(`${sourceProviderId}: refreshed ${records.length} discovered model(s) on configured provider`);
|
|
128
|
+
return models;
|
|
153
129
|
} catch (error) {
|
|
154
|
-
|
|
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
|
-
|
|
182
|
-
let
|
|
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
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
|
130
|
+
function reclaimStaleLock(dir: string, instanceId: string): boolean {
|
|
96
131
|
const expected = readOwner(dir);
|
|
97
|
-
if (!(expected ?
|
|
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(
|
|
103
|
-
catch (error: any) { log.warn(
|
|
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
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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);
|
|
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 || !
|
|
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
|
-
|
|
198
|
-
|
|
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");
|