@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4
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/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- package/src/ui/server/__tests__/path-containment.test.ts +0 -54
package/src/core/orchestrator.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Options for starting the orchestrator. */
|
|
2
2
|
export interface OrchestratorOptions {
|
|
3
|
-
/**
|
|
4
|
-
dataDir
|
|
3
|
+
/** Workspace root (not the `pipeline-data` subdir); omitted values fall back to `PO_ROOT` at boot. */
|
|
4
|
+
dataDir?: string;
|
|
5
5
|
/** Injection point for process spawner. Defaults to Bun.spawn. */
|
|
6
6
|
spawn?: SpawnFn;
|
|
7
7
|
/** Injection point for filesystem watcher factory. Defaults to chokidar.watch. */
|
|
@@ -76,7 +76,7 @@ type SpawnFn = (cmd: string[], options: {
|
|
|
76
76
|
}) => ChildHandle;
|
|
77
77
|
|
|
78
78
|
/** Result of a child process exit, capturing all diagnostic fields. */
|
|
79
|
-
interface ChildExitResult {
|
|
79
|
+
export interface ChildExitResult {
|
|
80
80
|
/** Exit code, or null if terminated by signal. */
|
|
81
81
|
code: number | null;
|
|
82
82
|
/** Signal name (e.g. "SIGTERM"), or null if exited normally. */
|
|
@@ -86,7 +86,7 @@ interface ChildExitResult {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** Minimal child process handle for tracking. */
|
|
89
|
-
interface ChildHandle {
|
|
89
|
+
export interface ChildHandle {
|
|
90
90
|
readonly pid: number;
|
|
91
91
|
/** Resolves with structured exit details when the process terminates. */
|
|
92
92
|
readonly exited: Promise<ChildExitResult>;
|
|
@@ -98,10 +98,22 @@ interface SpawnedRunner {
|
|
|
98
98
|
kill?: () => void;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
function describeCleanupError(err: unknown): unknown {
|
|
102
|
+
if (!(err instanceof AggregateError)) return err;
|
|
103
|
+
return {
|
|
104
|
+
name: err.name,
|
|
105
|
+
message: err.message,
|
|
106
|
+
errors: err.errors.map((cause) => {
|
|
107
|
+
if (cause instanceof Error) {
|
|
108
|
+
return { name: cause.name, message: cause.message, stack: cause.stack };
|
|
109
|
+
}
|
|
110
|
+
return { message: String(cause) };
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
103
114
|
|
|
104
|
-
import { join,
|
|
115
|
+
import { join, dirname, resolve } from "node:path";
|
|
116
|
+
import { readFileSync } from "node:fs";
|
|
105
117
|
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
106
118
|
import { watch } from "chokidar";
|
|
107
119
|
import { createLogger } from "./logger";
|
|
@@ -115,11 +127,15 @@ import { materializeNormalizedPipelineDefinition } from "./pipeline-definition";
|
|
|
115
127
|
import { discoverHarnesses } from "../harness/index.ts";
|
|
116
128
|
import { applyHarnessDiscovery } from "../harness/discovery.ts";
|
|
117
129
|
import {
|
|
130
|
+
listActiveJobLeases,
|
|
118
131
|
listQueuedSeeds,
|
|
132
|
+
pruneStaleJobSlots,
|
|
119
133
|
releaseJobSlot,
|
|
120
134
|
tryAcquireJobSlot,
|
|
121
135
|
updateJobSlotPid,
|
|
122
136
|
} from "./job-concurrency";
|
|
137
|
+
import { isProcessAlive, reconcileInterruptedJob, reconcileOrphanedCurrentJobs } from "./runner-liveness";
|
|
138
|
+
import { resolveWorkspaceRoot } from "../config/paths";
|
|
123
139
|
|
|
124
140
|
/**
|
|
125
141
|
* Normalize any path that may already include `pipeline-data` (or subdirs
|
|
@@ -184,7 +200,7 @@ export async function spawnRunner(
|
|
|
184
200
|
running: Map<string, ChildHandle>,
|
|
185
201
|
logger: ReturnType<typeof createLogger>,
|
|
186
202
|
spawnFn: SpawnFn,
|
|
187
|
-
onExit?: (jobId: string) => void | Promise<void>,
|
|
203
|
+
onExit?: (jobId: string, result: ChildExitResult) => void | Promise<void>,
|
|
188
204
|
): Promise<void> {
|
|
189
205
|
if (!seed.pipeline) {
|
|
190
206
|
throw new Error(`seed.pipeline is required for job ${jobId}`);
|
|
@@ -236,9 +252,9 @@ export async function spawnRunner(
|
|
|
236
252
|
});
|
|
237
253
|
if (!onExit) return;
|
|
238
254
|
try {
|
|
239
|
-
await onExit(jobId);
|
|
255
|
+
await onExit(jobId, result);
|
|
240
256
|
} catch (err) {
|
|
241
|
-
logger.error(`child exit cleanup failed for job ${jobId}`, err);
|
|
257
|
+
logger.error(`child exit cleanup failed for job ${jobId}`, describeCleanupError(err));
|
|
242
258
|
}
|
|
243
259
|
})
|
|
244
260
|
.catch((err) => {
|
|
@@ -251,11 +267,46 @@ export interface HandleChildExitOptions {
|
|
|
251
267
|
jobId: string;
|
|
252
268
|
triggerDrain: () => void;
|
|
253
269
|
lockTimeoutMs?: number;
|
|
270
|
+
code?: number | null;
|
|
271
|
+
signal?: string | null;
|
|
272
|
+
releaseSlot?: (dataDir: string, jobId: string, lockTimeoutMs?: number) => Promise<void>;
|
|
273
|
+
reconcileJob?: typeof reconcileInterruptedJob;
|
|
254
274
|
}
|
|
255
275
|
|
|
256
276
|
export async function handleChildExit(opts: HandleChildExitOptions): Promise<void> {
|
|
277
|
+
const lockTimeoutMs = opts.lockTimeoutMs ?? getOrchestratorConfig().lockFileTimeout;
|
|
278
|
+
const releaseSlot = opts.releaseSlot ?? releaseJobSlot;
|
|
279
|
+
const reconcileJob = opts.reconcileJob ?? reconcileInterruptedJob;
|
|
257
280
|
try {
|
|
258
|
-
|
|
281
|
+
let releaseError: unknown;
|
|
282
|
+
let reconcileError: unknown;
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await releaseSlot(opts.dataDir, opts.jobId, lockTimeoutMs);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
releaseError = err;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const jobDir = join(opts.dataDir, "current", opts.jobId);
|
|
291
|
+
try {
|
|
292
|
+
await reconcileJob({
|
|
293
|
+
dataDir: opts.dataDir,
|
|
294
|
+
jobId: opts.jobId,
|
|
295
|
+
jobDir,
|
|
296
|
+
source: "child-exit",
|
|
297
|
+
code: opts.code,
|
|
298
|
+
signal: opts.signal,
|
|
299
|
+
lockTimeoutMs,
|
|
300
|
+
});
|
|
301
|
+
} catch (err) {
|
|
302
|
+
reconcileError = err;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (releaseError && reconcileError) {
|
|
306
|
+
throw new AggregateError([releaseError, reconcileError], "child exit cleanup failed");
|
|
307
|
+
}
|
|
308
|
+
if (releaseError) throw releaseError;
|
|
309
|
+
if (reconcileError) throw reconcileError;
|
|
259
310
|
} finally {
|
|
260
311
|
opts.triggerDrain();
|
|
261
312
|
}
|
|
@@ -327,63 +378,6 @@ async function scaffoldJobDir(
|
|
|
327
378
|
await fileIO.writeLog(logName, JSON.stringify(startLog, null, 2), { mode: "replace" });
|
|
328
379
|
}
|
|
329
380
|
|
|
330
|
-
export async function handleSeedAdd(
|
|
331
|
-
filePath: string,
|
|
332
|
-
dirs: ResolvedDirs,
|
|
333
|
-
running: Map<string, ChildHandle>,
|
|
334
|
-
logger: ReturnType<typeof createLogger>,
|
|
335
|
-
opts: OrchestratorOptions
|
|
336
|
-
): Promise<void> {
|
|
337
|
-
const filename = basename(filePath);
|
|
338
|
-
const match = filename.match(SEED_PATTERN);
|
|
339
|
-
|
|
340
|
-
if (!match) {
|
|
341
|
-
logger.warn(`ignoring non-seed file: ${filename}`);
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const jobId = match[1]!;
|
|
346
|
-
|
|
347
|
-
let raw: string;
|
|
348
|
-
try {
|
|
349
|
-
raw = await Bun.file(filePath).text();
|
|
350
|
-
} catch (err) {
|
|
351
|
-
logger.warn(`failed to read ${filename}`, err);
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
let seed: SeedData;
|
|
356
|
-
try {
|
|
357
|
-
seed = JSON.parse(raw) as SeedData;
|
|
358
|
-
} catch (err) {
|
|
359
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
360
|
-
logger.warn(`invalid JSON in ${filename}: ${message}`);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (running.has(jobId)) return;
|
|
365
|
-
|
|
366
|
-
const jobDir = join(dirs.current, jobId);
|
|
367
|
-
const seedDest = join(jobDir, "seed.json");
|
|
368
|
-
if (await Bun.file(seedDest).exists()) return;
|
|
369
|
-
|
|
370
|
-
await mkdir(jobDir, { recursive: true });
|
|
371
|
-
try {
|
|
372
|
-
await rename(filePath, seedDest);
|
|
373
|
-
} catch (err) {
|
|
374
|
-
logger.error(`failed to move seed file for job ${jobId}`, err);
|
|
375
|
-
throw err;
|
|
376
|
-
}
|
|
377
|
-
await scaffoldJobDir(jobId, seed, dirs, logger);
|
|
378
|
-
|
|
379
|
-
const spawnFn = opts.spawn ?? createDefaultSpawn();
|
|
380
|
-
try {
|
|
381
|
-
await spawnRunner(jobId, seed, dirs, running, logger, spawnFn);
|
|
382
|
-
} catch (err) {
|
|
383
|
-
logger.error(`failed to spawn runner for job ${jobId}`, err);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
381
|
export async function stopChildren(
|
|
388
382
|
running: Map<string, ChildHandle>,
|
|
389
383
|
logger: ReturnType<typeof createLogger>
|
|
@@ -417,6 +411,90 @@ export async function stopChildren(
|
|
|
417
411
|
running.clear();
|
|
418
412
|
}
|
|
419
413
|
|
|
414
|
+
export interface AdoptOrphanedRunnersOptions {
|
|
415
|
+
dataDir: string;
|
|
416
|
+
currentDir: string;
|
|
417
|
+
running: Map<string, ChildHandle>;
|
|
418
|
+
isAlive?: (pid: number) => boolean;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function synthesizeAdoptedHandle(opts: {
|
|
422
|
+
pid: number;
|
|
423
|
+
jobId: string;
|
|
424
|
+
currentDir: string;
|
|
425
|
+
isAlive: (pid: number) => boolean;
|
|
426
|
+
}): ChildHandle {
|
|
427
|
+
const { pid, jobId, currentDir, isAlive } = opts;
|
|
428
|
+
let exitedPromise: Promise<ChildExitResult> | null = null;
|
|
429
|
+
return {
|
|
430
|
+
pid,
|
|
431
|
+
kill: (signal) => {
|
|
432
|
+
const runnerPidPath = join(currentDir, jobId, "runner.pid");
|
|
433
|
+
let raw: string;
|
|
434
|
+
try {
|
|
435
|
+
raw = readFileSync(runnerPidPath, "utf-8");
|
|
436
|
+
} catch (err) {
|
|
437
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const current = Number(raw.trim());
|
|
441
|
+
if (!Number.isInteger(current) || current !== pid) return;
|
|
442
|
+
try {
|
|
443
|
+
if (signal === undefined) process.kill(pid);
|
|
444
|
+
else process.kill(pid, signal);
|
|
445
|
+
} catch {
|
|
446
|
+
// ESRCH: already exited. EPERM: not ours. Either way, no-op.
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
get exited(): Promise<ChildExitResult> {
|
|
450
|
+
if (exitedPromise) return exitedPromise;
|
|
451
|
+
// Adopted handles are stop handles, not a background supervisor. Reading
|
|
452
|
+
// `exited` starts lazy polling only during shutdown/stop, and the signal
|
|
453
|
+
// completion sentinel is intentionally generic because the original exit
|
|
454
|
+
// code/signal is unavailable for a process we did not spawn.
|
|
455
|
+
exitedPromise = new Promise<ChildExitResult>((resolve) => {
|
|
456
|
+
const interval = setInterval(() => {
|
|
457
|
+
if (!isAlive(pid)) {
|
|
458
|
+
clearInterval(interval);
|
|
459
|
+
resolve({ code: null, signal: null, completionType: "signal" });
|
|
460
|
+
}
|
|
461
|
+
}, 100);
|
|
462
|
+
interval.unref();
|
|
463
|
+
});
|
|
464
|
+
return exitedPromise;
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function adoptOrphanedRunners(opts: AdoptOrphanedRunnersOptions): Promise<string[]> {
|
|
470
|
+
const alive = opts.isAlive ?? isProcessAlive;
|
|
471
|
+
const leases = await listActiveJobLeases(opts.dataDir);
|
|
472
|
+
const adopted: string[] = [];
|
|
473
|
+
for (const lease of leases) {
|
|
474
|
+
const pid = lease.pid;
|
|
475
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) continue;
|
|
476
|
+
if (!alive(pid)) continue;
|
|
477
|
+
const runnerPidPath = join(opts.currentDir, lease.jobId, "runner.pid");
|
|
478
|
+
let raw: string;
|
|
479
|
+
try {
|
|
480
|
+
raw = readFileSync(runnerPidPath, "utf-8");
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const filePid = Number(raw.trim());
|
|
486
|
+
if (!Number.isInteger(filePid) || filePid !== pid) continue;
|
|
487
|
+
opts.running.set(lease.jobId, synthesizeAdoptedHandle({
|
|
488
|
+
pid,
|
|
489
|
+
jobId: lease.jobId,
|
|
490
|
+
currentDir: opts.currentDir,
|
|
491
|
+
isAlive: alive,
|
|
492
|
+
}));
|
|
493
|
+
adopted.push(lease.jobId);
|
|
494
|
+
}
|
|
495
|
+
return adopted;
|
|
496
|
+
}
|
|
497
|
+
|
|
420
498
|
const MAX_PENDING_SEED_FAILURES = 3;
|
|
421
499
|
const pendingSeedFailures = new Map<string, { count: number; signature: string }>();
|
|
422
500
|
|
|
@@ -424,6 +502,7 @@ export interface DrainPendingQueueOptions {
|
|
|
424
502
|
dataDir: string;
|
|
425
503
|
maxConcurrentJobs: number;
|
|
426
504
|
lockTimeoutMs: number;
|
|
505
|
+
staleLeaseTimeoutMs: number;
|
|
427
506
|
spawnRunner: (jobId: string, seed: SeedData) => Promise<SpawnedRunner>;
|
|
428
507
|
}
|
|
429
508
|
|
|
@@ -540,7 +619,7 @@ async function readPendingSeed(seedPath: string): Promise<
|
|
|
540
619
|
export async function drainPendingQueue(
|
|
541
620
|
opts: DrainPendingQueueOptions,
|
|
542
621
|
): Promise<DrainPendingQueueResult> {
|
|
543
|
-
const { dataDir, maxConcurrentJobs, lockTimeoutMs, spawnRunner } = opts;
|
|
622
|
+
const { dataDir, maxConcurrentJobs, lockTimeoutMs, staleLeaseTimeoutMs, spawnRunner } = opts;
|
|
544
623
|
const logger = createLogger("orchestrator");
|
|
545
624
|
const queued = await listQueuedSeeds(dataDir);
|
|
546
625
|
const promoted: string[] = [];
|
|
@@ -569,6 +648,7 @@ export async function drainPendingQueue(
|
|
|
569
648
|
maxConcurrentJobs,
|
|
570
649
|
source: "orchestrator",
|
|
571
650
|
lockTimeoutMs,
|
|
651
|
+
staleLeaseTimeoutMs,
|
|
572
652
|
});
|
|
573
653
|
if (!acquired.ok) {
|
|
574
654
|
if (acquired.reason === "limit_reached") {
|
|
@@ -670,14 +750,17 @@ async function harnessPreflight(logger: ReturnType<typeof createLogger>): Promis
|
|
|
670
750
|
}
|
|
671
751
|
|
|
672
752
|
export function startOrchestrator(opts: OrchestratorOptions): Promise<OrchestratorHandle> {
|
|
673
|
-
|
|
753
|
+
const root = resolveWorkspaceRoot(opts.dataDir);
|
|
754
|
+
getConfig();
|
|
674
755
|
|
|
675
|
-
const dirs = resolveDirs(
|
|
756
|
+
const dirs = resolveDirs(root);
|
|
676
757
|
const factory = opts.watcherFactory ?? ((path, options) => watch(path, options) as unknown as Watcher);
|
|
677
758
|
const logger = createLogger("orchestrator");
|
|
678
759
|
const orchestratorConfig = getOrchestratorConfig();
|
|
679
760
|
const maxConcurrentJobs = orchestratorConfig.maxConcurrentJobs;
|
|
680
761
|
const lockTimeoutMs = orchestratorConfig.lockFileTimeout;
|
|
762
|
+
const staleLeaseTimeoutMs = orchestratorConfig.staleLeaseTimeoutMs;
|
|
763
|
+
const running = new Map<string, ChildHandle>();
|
|
681
764
|
|
|
682
765
|
return mkdir(dirs.pending, { recursive: true })
|
|
683
766
|
.then(() => mkdir(dirs.current, { recursive: true }))
|
|
@@ -685,8 +768,43 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
|
|
|
685
768
|
.then(() => mkdir(dirs.rejected, { recursive: true }))
|
|
686
769
|
.then(() => mkdir(dirs.staging, { recursive: true }))
|
|
687
770
|
.then(() => harnessPreflight(logger))
|
|
771
|
+
.then(async () => {
|
|
772
|
+
try {
|
|
773
|
+
const pruned = await pruneStaleJobSlots(dirs.dataDir, lockTimeoutMs, staleLeaseTimeoutMs);
|
|
774
|
+
logger.log("boot prune complete", { pruned });
|
|
775
|
+
} catch (err) {
|
|
776
|
+
logger.warn(
|
|
777
|
+
`boot prune failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const summary = await reconcileOrphanedCurrentJobs({
|
|
782
|
+
dataDir: dirs.dataDir,
|
|
783
|
+
currentDir: dirs.current,
|
|
784
|
+
lockTimeoutMs,
|
|
785
|
+
staleLeaseTimeoutMs,
|
|
786
|
+
staleAfterMs: getOrchestratorConfig().staleRunningGraceMs,
|
|
787
|
+
});
|
|
788
|
+
logger.log("boot reconciliation complete", summary);
|
|
789
|
+
} catch (err) {
|
|
790
|
+
logger.warn(
|
|
791
|
+
`boot reconciliation failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
const adopted = await adoptOrphanedRunners({
|
|
796
|
+
dataDir: dirs.dataDir,
|
|
797
|
+
currentDir: dirs.current,
|
|
798
|
+
running,
|
|
799
|
+
});
|
|
800
|
+
if (adopted.length > 0) logger.log("adopted orphaned runners", { adopted });
|
|
801
|
+
} catch (err) {
|
|
802
|
+
logger.warn(
|
|
803
|
+
`runner re-adoption failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
})
|
|
688
807
|
.then(() => new Promise<OrchestratorHandle>((resolve, reject) => {
|
|
689
|
-
const running = new Map<string, ChildHandle>();
|
|
690
808
|
const spawnFn = opts.spawn ?? createDefaultSpawn();
|
|
691
809
|
|
|
692
810
|
let isDraining = false;
|
|
@@ -709,6 +827,7 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
|
|
|
709
827
|
dataDir: dirs.dataDir,
|
|
710
828
|
maxConcurrentJobs,
|
|
711
829
|
lockTimeoutMs,
|
|
830
|
+
staleLeaseTimeoutMs,
|
|
712
831
|
spawnRunner: spawnRunnerForJob,
|
|
713
832
|
});
|
|
714
833
|
} while (pendingDrain);
|
|
@@ -721,9 +840,16 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
|
|
|
721
840
|
})();
|
|
722
841
|
};
|
|
723
842
|
|
|
724
|
-
const onChildExit = (jobId: string): Promise<void> => {
|
|
843
|
+
const onChildExit = (jobId: string, result: ChildExitResult): Promise<void> => {
|
|
725
844
|
if (stopped) return Promise.resolve();
|
|
726
|
-
return handleChildExit({
|
|
845
|
+
return handleChildExit({
|
|
846
|
+
dataDir: dirs.dataDir,
|
|
847
|
+
jobId,
|
|
848
|
+
triggerDrain,
|
|
849
|
+
lockTimeoutMs,
|
|
850
|
+
code: result.code,
|
|
851
|
+
signal: result.signal,
|
|
852
|
+
});
|
|
727
853
|
};
|
|
728
854
|
|
|
729
855
|
const spawnRunnerForJob = async (jobId: string, seed: SeedData): Promise<SpawnedRunner> => {
|