@runuai/host 0.9.158 → 0.9.160
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/orchestrator.ts
CHANGED
|
@@ -9374,10 +9374,54 @@ async function recoverPersistedTaskEnvironment(
|
|
|
9374
9374
|
return result.outcome === "recovered";
|
|
9375
9375
|
} catch (error) {
|
|
9376
9376
|
if (tunnelReadinessFailed) return false;
|
|
9377
|
+
if (error instanceof TaskRecoveryRowConflictError && isCurrent()) {
|
|
9378
|
+
return quarantineConflictingTask(task, environment, error, isCurrent, lifecycle);
|
|
9379
|
+
}
|
|
9377
9380
|
throw error;
|
|
9378
9381
|
}
|
|
9379
9382
|
}
|
|
9380
9383
|
|
|
9384
|
+
/** A durable row the provider can never reconcile with its locator. Retrying
|
|
9385
|
+
* cannot change the verdict, so the sweep must not treat it as an unknown
|
|
9386
|
+
* runtime state: that kept the whole runtime in checking, retrying every
|
|
9387
|
+
* ~50 s, while one task's row was wrong (2026-10-19). */
|
|
9388
|
+
export class TaskRecoveryRowConflictError extends Error {
|
|
9389
|
+
override name = "TaskRecoveryRowConflictError";
|
|
9390
|
+
}
|
|
9391
|
+
|
|
9392
|
+
/** Settle one task whose row conflicts with its locator without holding the
|
|
9393
|
+
* runtime: drain its consumers, stop the locator's environment, and mark the
|
|
9394
|
+
* row `error` so the task reads as failed rather than running. Only a stop
|
|
9395
|
+
* that cannot be proven throws on, keeping the sweep unresolved, because
|
|
9396
|
+
* then the runtime state really is unknown. Resume or Destroy clears it. */
|
|
9397
|
+
async function quarantineConflictingTask(
|
|
9398
|
+
task: typeof schema.hostTasks.$inferSelect,
|
|
9399
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
9400
|
+
conflict: TaskRecoveryRowConflictError,
|
|
9401
|
+
isCurrent: () => boolean,
|
|
9402
|
+
lifecycle?: Orchestrator,
|
|
9403
|
+
): Promise<boolean> {
|
|
9404
|
+
await lifecycle?.quarantineChannelForRecovery(task.taskId);
|
|
9405
|
+
if (!isCurrent()) return true;
|
|
9406
|
+
await environment.stop();
|
|
9407
|
+
const after = await environment.status();
|
|
9408
|
+
if (!isCurrent()) return true;
|
|
9409
|
+
if (after.state === "running" || after.state === "unknown") {
|
|
9410
|
+
throw new Error(
|
|
9411
|
+
`${conflict.message}, and its environment could not be proven stopped`,
|
|
9412
|
+
);
|
|
9413
|
+
}
|
|
9414
|
+
db_setStatus(task.taskId, "error", {
|
|
9415
|
+
codeServerPort: null,
|
|
9416
|
+
previewPorts: "[]",
|
|
9417
|
+
});
|
|
9418
|
+
clearTaskGatewayAcl(task.taskId);
|
|
9419
|
+
console.error(
|
|
9420
|
+
`[orchestrator] recovery: ${task.taskId} quarantined: ${conflict.message}; its environment was stopped and the task marked error so the runtime can go operational`,
|
|
9421
|
+
);
|
|
9422
|
+
return true;
|
|
9423
|
+
}
|
|
9424
|
+
|
|
9381
9425
|
interface RecoveryTaskConsumers {
|
|
9382
9426
|
quarantineChannelForRecovery(taskId: string): Promise<void>;
|
|
9383
9427
|
prepareChannelForRecovery(taskId: string): Promise<void>;
|
|
@@ -10020,23 +10064,28 @@ async function recoverDockerTaskEnvironment(
|
|
|
10020
10064
|
if (!task || !task.statusMirror || !isActive(task.statusMirror)) {
|
|
10021
10065
|
return { outcome: "recovered" };
|
|
10022
10066
|
}
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10067
|
+
// A starting/error row may hold only part of its legacy mirrors: a crash
|
|
10068
|
+
// before task-up finished leaves both NULL, and a start that failed after
|
|
10069
|
+
// an earlier recovery settled the row half-written leaves compose_project
|
|
10070
|
+
// set with worktree_path NULL (live 2026-10-19: one such row held the
|
|
10071
|
+
// whole runtime in checking for 2.5 h). Each mirror present must still
|
|
10072
|
+
// equal the locator; a missing one is adopted from it.
|
|
10073
|
+
const mayAdoptLocator =
|
|
10074
|
+
task.statusMirror === "starting" || task.statusMirror === "error";
|
|
10075
|
+
const mirrorAgrees = (mirror: string | null, expected: string): boolean =>
|
|
10076
|
+
mirror === expected || (mayAdoptLocator && mirror === null);
|
|
10027
10077
|
if (
|
|
10028
|
-
!
|
|
10029
|
-
(task.
|
|
10030
|
-
task.worktreePath !== locator.hostWorktreePath)
|
|
10078
|
+
!mirrorAgrees(task.composeProject, locator.composeProject) ||
|
|
10079
|
+
!mirrorAgrees(task.worktreePath, locator.hostWorktreePath)
|
|
10031
10080
|
) {
|
|
10032
|
-
throw new
|
|
10081
|
+
throw new TaskRecoveryRowConflictError(
|
|
10033
10082
|
`task ${descriptor.taskId} environment locator no longer matches its durable row`,
|
|
10034
10083
|
);
|
|
10035
10084
|
}
|
|
10036
10085
|
// Provider-owned provisional metadata is authoritative after a crash. Feed
|
|
10037
10086
|
// the already-validated deterministic values to the Docker-private repair
|
|
10038
10087
|
// helpers without teaching the generic sweep how to derive them.
|
|
10039
|
-
const recoveryTask =
|
|
10088
|
+
const recoveryTask = mayAdoptLocator
|
|
10040
10089
|
? {
|
|
10041
10090
|
...task,
|
|
10042
10091
|
composeProject: locator.composeProject,
|
|
@@ -10147,7 +10196,13 @@ async function recoverOneDockerTask(
|
|
|
10147
10196
|
db_setStatus(task.taskId, next, {
|
|
10148
10197
|
codeServerPort: null,
|
|
10149
10198
|
previewPorts: "[]",
|
|
10150
|
-
|
|
10199
|
+
// A prepared row reaches here with both mirrors adopted from its
|
|
10200
|
+
// locator: write them back as a pair. Writing compose_project alone
|
|
10201
|
+
// left a stopped row whose next failed Resume became the half-written
|
|
10202
|
+
// error row of 2026-10-19.
|
|
10203
|
+
...(next === "error"
|
|
10204
|
+
? { composeProject: null }
|
|
10205
|
+
: { composeProject, worktreePath: task.worktreePath }),
|
|
10151
10206
|
});
|
|
10152
10207
|
console.log(
|
|
10153
10208
|
`[orchestrator] recovery: ${task.taskId} -> ${next} (container gone, ` +
|
|
@@ -243,6 +243,77 @@ export function taskMachinePeerProcessIds(): number[] {
|
|
|
243
243
|
);
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/** A process's parent from /proc; null once the process is gone. */
|
|
247
|
+
export function processParentId(pid: number): number | null {
|
|
248
|
+
let stat: string;
|
|
249
|
+
try {
|
|
250
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
// `pid (comm) state ppid ...`: comm may itself hold spaces or parens.
|
|
256
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
257
|
+
if (!Number.isSafeInteger(ppid) || ppid < 0) {
|
|
258
|
+
throw new Error(`process ${pid} has an unreadable parent`);
|
|
259
|
+
}
|
|
260
|
+
return ppid;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Every process in the sidecar service cgroup that this runner did not start.
|
|
265
|
+
* The fresh-incarnation proof asks this, not `taskMachinePeerProcessIds`: the
|
|
266
|
+
* runner's own helpers share its cgroup from boot, tsx's esbuild service above
|
|
267
|
+
* all (a long-lived child of every runner that compiled a module; live
|
|
268
|
+
* 2026-10-20, every marked machine stayed pending "not quiescent" on it), and
|
|
269
|
+
* a pending runner admits no task, so nothing it started is a task process.
|
|
270
|
+
* What an earlier runner process left behind (KillMode=process) was reparented
|
|
271
|
+
* when that process ended: it is never this runner's descendant, so it counts.
|
|
272
|
+
*/
|
|
273
|
+
export function taskMachineForeignProcessIds(): number[] {
|
|
274
|
+
return foreignProcessIdsWith({
|
|
275
|
+
list: taskMachinePeerProcessIds,
|
|
276
|
+
parent: processParentId,
|
|
277
|
+
self: process.pid,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface ForeignProcessDeps {
|
|
282
|
+
list(): readonly number[];
|
|
283
|
+
parent(pid: number): number | null;
|
|
284
|
+
self: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Exported seam keeps the ancestry walk executable in tests. */
|
|
288
|
+
export function foreignProcessIdsWith(deps: ForeignProcessDeps): number[] {
|
|
289
|
+
const foreign: number[] = [];
|
|
290
|
+
for (const pid of deps.list()) {
|
|
291
|
+
if (pid === deps.self) continue;
|
|
292
|
+
const seen = new Set<number>();
|
|
293
|
+
let current: number | null = pid;
|
|
294
|
+
let ancestry: "ours" | "foreign" | "gone" = "foreign";
|
|
295
|
+
for (;;) {
|
|
296
|
+
const parent = deps.parent(current);
|
|
297
|
+
if (parent === null) {
|
|
298
|
+
// The process itself is gone: nothing to count. An ancestor gone
|
|
299
|
+
// mid-walk leaves an orphan the next look sees reparented: fail
|
|
300
|
+
// closed now, as that look would.
|
|
301
|
+
if (current === pid) ancestry = "gone";
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
if (parent === deps.self) {
|
|
305
|
+
ancestry = "ours";
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (parent <= 1 || seen.has(parent)) break;
|
|
309
|
+
seen.add(parent);
|
|
310
|
+
current = parent;
|
|
311
|
+
}
|
|
312
|
+
if (ancestry === "foreign") foreign.push(pid);
|
|
313
|
+
}
|
|
314
|
+
return foreign;
|
|
315
|
+
}
|
|
316
|
+
|
|
246
317
|
/** Kill everything in the sidecar service cgroup except this root runner.
|
|
247
318
|
* The task's agents, editor, terminals and sudo children all descend from the
|
|
248
319
|
* service; KillMode=process is precisely why they otherwise survive. */
|
|
@@ -26,6 +26,8 @@ export interface TaskMachineCredentialsActivationDeps {
|
|
|
26
26
|
filesReady(): boolean;
|
|
27
27
|
retirementReady(): boolean;
|
|
28
28
|
taskRows(): readonly { taskId: string }[];
|
|
29
|
+
/** Processes in the sidecar's service cgroup that this runner did not
|
|
30
|
+
* start (its own helpers, such as tsx's esbuild service, never count). */
|
|
29
31
|
peerProcessIds(): readonly number[];
|
|
30
32
|
}
|
|
31
33
|
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -428,7 +428,7 @@ import {
|
|
|
428
428
|
kernelBootId,
|
|
429
429
|
TaskFilesMachineRetirementOutbox,
|
|
430
430
|
TASK_FILES_MACHINE_RETIREMENT_RECORD,
|
|
431
|
-
|
|
431
|
+
taskMachineForeignProcessIds,
|
|
432
432
|
} from "../lib/task-files-machine-retirement-outbox";
|
|
433
433
|
import {
|
|
434
434
|
planFreshTaskMachineCredentialsActivation,
|
|
@@ -1819,7 +1819,8 @@ function taskMachineCredentialsActivationDeps(): TaskMachineCredentialsActivatio
|
|
|
1819
1819
|
.select({ taskId: schema.hostTasks.taskId })
|
|
1820
1820
|
.from(schema.hostTasks)
|
|
1821
1821
|
.all(),
|
|
1822
|
-
|
|
1822
|
+
// Not the runner's own children (tsx's esbuild service): see the helper.
|
|
1823
|
+
peerProcessIds: taskMachineForeignProcessIds,
|
|
1823
1824
|
};
|
|
1824
1825
|
}
|
|
1825
1826
|
|