@runuai/host 0.9.42 → 0.9.44
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/engine-login.ts +7 -1
- package/lib/managed-runtime.ts +70 -6
- package/lib/orchestrator.ts +12 -3
- package/lib/preview-sidecar.ts +11 -1
- package/lib/task-environment/docker.ts +15 -4
- package/lib/task-environment/legacy-adoption.ts +27 -11
- package/package.json +1 -1
- package/src/index.ts +14 -2
- package/src/main.ts +26 -1
package/lib/engine-login.ts
CHANGED
|
@@ -1286,7 +1286,13 @@ export function engineLoginContainerArgs(
|
|
|
1286
1286
|
? [
|
|
1287
1287
|
"/usr/bin/script",
|
|
1288
1288
|
"-qefc",
|
|
1289
|
-
|
|
1289
|
+
// A wide PTY first: `script` allocates 80×24 when stdin is no
|
|
1290
|
+
// terminal, the CLI hard-wraps the OAuth URL at that width, and
|
|
1291
|
+
// the streamed capture then sees only the first fragment — the
|
|
1292
|
+
// operator lands on claude.ai with "Missing redirect_uri" and half
|
|
1293
|
+
// a client id (live 2026-08-18). stty targets the PTY `script`
|
|
1294
|
+
// just allocated.
|
|
1295
|
+
"stty cols 500 rows 50 2>/dev/null; exec claude setup-token",
|
|
1290
1296
|
"/dev/null",
|
|
1291
1297
|
]
|
|
1292
1298
|
: ["codex", "login"];
|
package/lib/managed-runtime.ts
CHANGED
|
@@ -806,12 +806,62 @@ export function managedRuntimeActivationPending(home?: string): boolean {
|
|
|
806
806
|
* taskUp and must be repaired by a safe uninstall retry.
|
|
807
807
|
*/
|
|
808
808
|
export function managedRuntimeAdmissionPending(home?: string): boolean {
|
|
809
|
+
return managedRuntimeAdmissionBlocker(home) !== null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The exact file closing task admission, or null. Refusals must NAME their
|
|
813
|
+
* gate: three stacked silent refusals cost a live operator hours
|
|
814
|
+
* (2026-08-18) because nothing anywhere said which file to look at. */
|
|
815
|
+
export function managedRuntimeAdmissionBlocker(home?: string): string | null {
|
|
809
816
|
const paths = managedRuntimePaths(home);
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
)
|
|
817
|
+
for (const marker of [
|
|
818
|
+
paths.activationPending,
|
|
819
|
+
paths.maintenancePending,
|
|
820
|
+
paths.installIntent,
|
|
821
|
+
]) {
|
|
822
|
+
if (pathExistsSync(marker)) return marker;
|
|
823
|
+
}
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Boot-time self-heal for the recurring silent-gate incident (live
|
|
829
|
+
* 2026-08-11 and twice on 2026-08-18): an uninstall/update helper that died
|
|
830
|
+
* at phase "checking" leaves its admission gate behind forever, and every
|
|
831
|
+
* taskUp / channelEnsure then bounces retryably with no feedback anywhere.
|
|
832
|
+
*
|
|
833
|
+
* A "checking" marker is reclaimable by the flow's OWN contract — no
|
|
834
|
+
* teardown has begun at that phase (removeManagedRuntime reopens admission
|
|
835
|
+
* itself on an ordinary busy refusal) — and a marker observed while holding
|
|
836
|
+
* the operation lock cannot belong to a live operation. Later phases
|
|
837
|
+
* (teardown/cleanup) and unreadable markers stay fail-closed: state may be
|
|
838
|
+
* half-removed and only an uninstall retry may touch them.
|
|
839
|
+
*/
|
|
840
|
+
export async function reclaimAbandonedMaintenanceCheck(
|
|
841
|
+
home?: string,
|
|
842
|
+
): Promise<"cleared" | "kept" | "none"> {
|
|
843
|
+
const paths = managedRuntimePaths(home);
|
|
844
|
+
if (!pathExistsSync(paths.maintenancePending)) return "none";
|
|
845
|
+
let release: () => Promise<void>;
|
|
846
|
+
try {
|
|
847
|
+
release = await acquireManagedOperationLock(paths);
|
|
848
|
+
} catch {
|
|
849
|
+
// A live install/update/uninstall owns the gate; leave it alone.
|
|
850
|
+
return "kept";
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
let marker: MaintenanceMarker | null;
|
|
854
|
+
try {
|
|
855
|
+
marker = await readMaintenanceMarker(paths);
|
|
856
|
+
} catch {
|
|
857
|
+
return "kept";
|
|
858
|
+
}
|
|
859
|
+
if (!marker || marker.phase !== "checking") return "kept";
|
|
860
|
+
await removeDurably(paths.maintenancePending);
|
|
861
|
+
return "cleared";
|
|
862
|
+
} finally {
|
|
863
|
+
await release();
|
|
864
|
+
}
|
|
815
865
|
}
|
|
816
866
|
|
|
817
867
|
/**
|
|
@@ -1448,7 +1498,21 @@ export async function removeManagedRuntime(
|
|
|
1448
1498
|
// before inspecting SQLite; taskUp writes `starting` before its final gate
|
|
1449
1499
|
// check. Whichever arrives second observes the first, so teardown cannot
|
|
1450
1500
|
// begin while an unrecorded task proceeds into Docker side effects.
|
|
1451
|
-
|
|
1501
|
+
let removable: boolean;
|
|
1502
|
+
try {
|
|
1503
|
+
removable = await options.canRemove();
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
if (marker.phase === "checking") {
|
|
1506
|
+
// The check itself failed, so no teardown began — leaving the gate
|
|
1507
|
+
// behind here poisoned every taskUp/channelEnsure with a silent
|
|
1508
|
+
// retryable refusal until an operator found the file (live
|
|
1509
|
+
// 2026-08-18, twice). Same safety argument as the busy refusal
|
|
1510
|
+
// below: reopen admission, keep the error.
|
|
1511
|
+
await removeDurably(paths.maintenancePending);
|
|
1512
|
+
}
|
|
1513
|
+
throw error;
|
|
1514
|
+
}
|
|
1515
|
+
if (!removable) {
|
|
1452
1516
|
if (marker.phase === "checking") {
|
|
1453
1517
|
// No teardown began, so this ordinary busy refusal may safely reopen
|
|
1454
1518
|
// admission. A retry of a previously-started teardown stays fail-closed.
|
package/lib/orchestrator.ts
CHANGED
|
@@ -111,7 +111,10 @@ import {
|
|
|
111
111
|
type ResolvedEngineAccount,
|
|
112
112
|
} from "./engine-accounts";
|
|
113
113
|
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
114
|
-
import {
|
|
114
|
+
import {
|
|
115
|
+
PREVIEW_SIDECAR_TASK_LABEL,
|
|
116
|
+
stopPreviewSidecars,
|
|
117
|
+
} from "./preview-sidecar";
|
|
115
118
|
import {
|
|
116
119
|
agentClisReady,
|
|
117
120
|
standardImageCorepackVersionPath,
|
|
@@ -4110,8 +4113,12 @@ async function dockerStart(containerName: string): Promise<DockerStartResult> {
|
|
|
4110
4113
|
return "failed";
|
|
4111
4114
|
}
|
|
4112
4115
|
|
|
4116
|
+
// The fourth column is the preview-sidecar marker: a sidecar can wear the
|
|
4117
|
+
// task's compose labels, and a row that is neither app-shaped nor a known
|
|
4118
|
+
// sidecar reads as AMBIGUOUS here — which fenced maintenance on a healthy
|
|
4119
|
+
// host (live 2026-08-18).
|
|
4113
4120
|
const RUNTIME_WRITER_LIST_FORMAT =
|
|
4114
|
-
|
|
4121
|
+
`{{json .Names}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
|
|
4115
4122
|
const RUNTIME_WRITER_INSPECT_FORMAT = [
|
|
4116
4123
|
"{{json .Id}}",
|
|
4117
4124
|
"{{json .Name}}",
|
|
@@ -4233,10 +4240,12 @@ async function listRuntimeWriterCandidates(): Promise<
|
|
|
4233
4240
|
for (const rawLine of listed.stdout.split(/\r?\n/)) {
|
|
4234
4241
|
if (!rawLine) continue;
|
|
4235
4242
|
const fields = rawLine.split("\t");
|
|
4236
|
-
if (fields.length !==
|
|
4243
|
+
if (fields.length !== 4) {
|
|
4237
4244
|
ambiguous = true;
|
|
4238
4245
|
continue;
|
|
4239
4246
|
}
|
|
4247
|
+
// A preview sidecar is a known non-writer, not an ambiguity.
|
|
4248
|
+
if (fields[3] !== "") continue;
|
|
4240
4249
|
let name: unknown;
|
|
4241
4250
|
try {
|
|
4242
4251
|
name = JSON.parse(fields[0]!);
|
package/lib/preview-sidecar.ts
CHANGED
|
@@ -21,7 +21,17 @@ import { inspectTunnelContainer } from "./tunnel-runtime";
|
|
|
21
21
|
|
|
22
22
|
/** Port the proxy listens on inside the sidecar (published to a random host port). */
|
|
23
23
|
const INNER_PORT = 9000;
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Exported for every "which container is the task's APP" scan: sidecars can
|
|
26
|
+
* legitimately wear the task's compose project/service labels (teardown
|
|
27
|
+
* finds them by project), so any container carrying THIS label must be
|
|
28
|
+
* excluded before counting app containers. Live 2026-08-18: one browser
|
|
29
|
+
* sidecar with compose labels made legacy adoption count two app containers
|
|
30
|
+
* and fail the whole runtime selection closed — every task on the host read
|
|
31
|
+
* as "still starting".
|
|
32
|
+
*/
|
|
33
|
+
export const PREVIEW_SIDECAR_TASK_LABEL = "com.runuai.preview-sidecar.task";
|
|
34
|
+
const LABEL = PREVIEW_SIDECAR_TASK_LABEL;
|
|
25
35
|
const TARGET_LABEL = "com.runuai.preview-sidecar.target";
|
|
26
36
|
|
|
27
37
|
/** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "../agent";
|
|
17
17
|
import { dockerCli, type DockerResult } from "../docker-exec";
|
|
18
18
|
import { taskDir } from "../env";
|
|
19
|
+
import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
|
|
19
20
|
import { assertSafeHostTaskId } from "../task-identity";
|
|
20
21
|
import {
|
|
21
22
|
assertTaskEnvironmentProcessRequest,
|
|
@@ -868,8 +869,12 @@ function taskStatusToEnvironmentStatus(
|
|
|
868
869
|
};
|
|
869
870
|
}
|
|
870
871
|
|
|
872
|
+
// The fourth column is the preview-sidecar marker: a sidecar wearing the
|
|
873
|
+
// task's compose labels must read as "not the app", not as malformed
|
|
874
|
+
// identity data — the latter turned a healthy running task's status into
|
|
875
|
+
// "unknown" (live 2026-08-18).
|
|
871
876
|
const DOCKER_STATUS_FORMAT =
|
|
872
|
-
|
|
877
|
+
`{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.State}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
|
|
873
878
|
|
|
874
879
|
async function inspectDockerTaskEnvironmentStatus(
|
|
875
880
|
docker: typeof dockerCli,
|
|
@@ -897,14 +902,20 @@ async function inspectDockerTaskEnvironmentStatus(
|
|
|
897
902
|
|
|
898
903
|
let state: string | undefined;
|
|
899
904
|
for (const raw of result.stdout.split(/\r?\n/)) {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
905
|
+
// No trim before splitting: the app row's LAST column (the sidecar
|
|
906
|
+
// marker) is empty, and trimming would eat its tab and shift the shape.
|
|
907
|
+
if (!raw.trim()) continue;
|
|
908
|
+
const [name, project, candidateState, sidecar, ...extra] = raw.split("\t");
|
|
909
|
+
// A preview sidecar wearing the task's compose labels is not the app
|
|
910
|
+
// container; skipping it keeps a healthy task from reading as unknown.
|
|
911
|
+
if (sidecar !== undefined && sidecar !== "") continue;
|
|
903
912
|
if (
|
|
904
913
|
state !== undefined ||
|
|
905
914
|
name !== environment.containerName ||
|
|
906
915
|
project !== environment.composeProject ||
|
|
907
916
|
!candidateState ||
|
|
917
|
+
!candidateState.trim() ||
|
|
918
|
+
sidecar === undefined ||
|
|
908
919
|
extra.length > 0
|
|
909
920
|
) {
|
|
910
921
|
return {
|
|
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
|
|
|
5
5
|
import { dockerCli, type DockerResult } from "../docker-exec";
|
|
6
6
|
import { getDb, schema, type HostTask } from "../db";
|
|
7
7
|
import { parsePreviewPortRuntimes } from "../preview-ports";
|
|
8
|
+
import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
|
|
8
9
|
import { assertSafeHostTaskId } from "../task-identity";
|
|
9
10
|
import {
|
|
10
11
|
APPLE_TASK_ENVIRONMENT_PROVIDER,
|
|
@@ -26,7 +27,11 @@ const INSPECT_OUTPUT_LIMIT = 128 * 1024;
|
|
|
26
27
|
const INVENTORY_OUTPUT_LIMIT = 128 * 1024;
|
|
27
28
|
const LEGACY_INSPECT_FORMAT =
|
|
28
29
|
'{{json .Id}}\t{{json .Name}}\t{{json (index .Config.Labels "com.docker.compose.project")}}\t{{json (index .Config.Labels "com.docker.compose.service")}}\t{{json .State.Status}}\t{{json .Mounts}}';
|
|
29
|
-
|
|
30
|
+
// The third column is the preview-sidecar marker: a sidecar can wear the
|
|
31
|
+
// task's compose project/service labels (teardown finds it by project), so
|
|
32
|
+
// the app-container census must see and skip it — counting one as an app
|
|
33
|
+
// container failed the WHOLE runtime adoption closed (live 2026-08-18).
|
|
34
|
+
const LEGACY_INVENTORY_FORMAT = `{{.ID}}\t{{.Names}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
|
|
30
35
|
|
|
31
36
|
type LegacyTaskRow = Pick<
|
|
32
37
|
HostTask,
|
|
@@ -317,27 +322,38 @@ async function proveNoConflictingAppContainer(
|
|
|
317
322
|
) {
|
|
318
323
|
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
319
324
|
}
|
|
325
|
+
if (result.stdout !== "" && !result.stdout.endsWith("\n")) {
|
|
326
|
+
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
327
|
+
}
|
|
328
|
+
const appRows: string[][] = [];
|
|
329
|
+
for (const line of result.stdout === ""
|
|
330
|
+
? []
|
|
331
|
+
: result.stdout.slice(0, -1).split("\n")) {
|
|
332
|
+
const fields = line.split("\t");
|
|
333
|
+
if (fields.length !== 3) {
|
|
334
|
+
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
335
|
+
}
|
|
336
|
+
// A preview sidecar wearing the task's compose labels is not an app
|
|
337
|
+
// container; counting one here failed the whole runtime adoption closed
|
|
338
|
+
// (live 2026-08-18, six labeled browser sidecars on the Studio).
|
|
339
|
+
if (fields[2] !== "") continue;
|
|
340
|
+
appRows.push(fields);
|
|
341
|
+
}
|
|
320
342
|
if (expected === null) {
|
|
321
343
|
// The exact container is proven absent; adoption is sound only if no
|
|
322
|
-
// same-project replacement exists either
|
|
323
|
-
|
|
324
|
-
if (result.stdout !== "") {
|
|
344
|
+
// same-project replacement exists either.
|
|
345
|
+
if (appRows.length !== 0) {
|
|
325
346
|
throw new Error(
|
|
326
347
|
`task ${candidate.row.taskId} has conflicting app containers`,
|
|
327
348
|
);
|
|
328
349
|
}
|
|
329
350
|
return;
|
|
330
351
|
}
|
|
331
|
-
if (
|
|
332
|
-
throw new Error(`task ${candidate.row.taskId} app inventory failed`);
|
|
333
|
-
}
|
|
334
|
-
const lines = result.stdout.slice(0, -1).split("\n");
|
|
335
|
-
if (lines.length !== 1) {
|
|
352
|
+
if (appRows.length !== 1) {
|
|
336
353
|
throw new Error(`task ${candidate.row.taskId} has conflicting app containers`);
|
|
337
354
|
}
|
|
338
|
-
const fields =
|
|
355
|
+
const fields = appRows[0]!;
|
|
339
356
|
if (
|
|
340
|
-
fields.length !== 2 ||
|
|
341
357
|
fields[0] !== expected.containerId ||
|
|
342
358
|
fields[1] !== expected.containerName
|
|
343
359
|
) {
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
} from "../lib/preview-sidecar";
|
|
76
76
|
import { inspectTunnelContainer } from "../lib/tunnel-runtime";
|
|
77
77
|
import { ContainerRuntimeUnavailableError } from "../lib/runtime-guard";
|
|
78
|
-
import {
|
|
78
|
+
import { managedRuntimeAdmissionBlocker } from "../lib/managed-runtime";
|
|
79
79
|
import { retireTaskSshIdentityEnsures } from "../lib/git-identity";
|
|
80
80
|
import { removeTaskIdentityStrict } from "../lib/ssh";
|
|
81
81
|
import { proveOrphanTaskResourcesAbsent } from "../lib/task-inventory";
|
|
@@ -1346,8 +1346,20 @@ function boundedFailureMessage(error: unknown): string {
|
|
|
1346
1346
|
}
|
|
1347
1347
|
|
|
1348
1348
|
/** Managed installation, activation, and uninstall drain instead of admitting work. */
|
|
1349
|
+
let lastAdmissionRefusalLogAt = 0;
|
|
1349
1350
|
function managedMaintenanceUnavailable(): HostCommandResult<never> | null {
|
|
1350
|
-
|
|
1351
|
+
const blocker = managedRuntimeAdmissionBlocker();
|
|
1352
|
+
if (blocker === null) return null;
|
|
1353
|
+
// A refusal must NAME its gate somewhere an operator can find it. Three
|
|
1354
|
+
// stacked silent refusals cost hours live (2026-08-18); rate-limited so a
|
|
1355
|
+
// retrying cloud cannot flood the log.
|
|
1356
|
+
const now = Date.now();
|
|
1357
|
+
if (now - lastAdmissionRefusalLogAt > 60_000) {
|
|
1358
|
+
lastAdmissionRefusalLogAt = now;
|
|
1359
|
+
console.warn(
|
|
1360
|
+
`[host-agent] refusing task admission: managed-maintenance marker present at ${blocker}`,
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1351
1363
|
return {
|
|
1352
1364
|
ok: false,
|
|
1353
1365
|
code: HostErrorCode.HostUnavailable,
|
package/src/main.ts
CHANGED
|
@@ -127,7 +127,11 @@ import {
|
|
|
127
127
|
onChange as onRegistryChange,
|
|
128
128
|
} from "../lib/agents/registry";
|
|
129
129
|
import { canAdvertiseTypedSecretaryDispatch } from "../lib/agents/mode";
|
|
130
|
-
import {
|
|
130
|
+
import {
|
|
131
|
+
MANAGED_UPDATE_RESTART_EXIT_CODE,
|
|
132
|
+
managedRuntimeAdmissionBlocker,
|
|
133
|
+
reclaimAbandonedMaintenanceCheck,
|
|
134
|
+
} from "../lib/managed-runtime";
|
|
131
135
|
import { ManagedOperationDrain } from "../lib/managed-operation-drain";
|
|
132
136
|
import {
|
|
133
137
|
createHostLogManager,
|
|
@@ -371,6 +375,27 @@ interface PausableSource {
|
|
|
371
375
|
}
|
|
372
376
|
|
|
373
377
|
console.log(`[host-agent] starting host ${hostId}`);
|
|
378
|
+
// Self-heal the recurring silent admission gate BEFORE anything can refuse
|
|
379
|
+
// work: an uninstall/update helper that died at phase "checking" (live
|
|
380
|
+
// 2026-08-11 + twice 2026-08-18) left every taskUp/channelEnsure bouncing
|
|
381
|
+
// retryably with no feedback. Only a provably-untorn "checking" marker is
|
|
382
|
+
// reclaimed; anything else stays fail-closed and is NAMED in the log.
|
|
383
|
+
try {
|
|
384
|
+
const reclaim = await reclaimAbandonedMaintenanceCheck();
|
|
385
|
+
if (reclaim === "cleared") {
|
|
386
|
+
console.warn(
|
|
387
|
+
"[host-agent] cleared an abandoned maintenance marker (a managed uninstall/update died before any teardown); task admission reopened",
|
|
388
|
+
);
|
|
389
|
+
} else if (reclaim === "kept") {
|
|
390
|
+
console.warn(
|
|
391
|
+
`[host-agent] task admission is CLOSED by a managed-maintenance marker: ${managedRuntimeAdmissionBlocker() ?? "(marker path unavailable)"} — retry the pending uninstall/update or remove the marker to reopen`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
} catch (error) {
|
|
395
|
+
console.warn(
|
|
396
|
+
`[host-agent] abandoned-maintenance reclaim failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
374
399
|
migrateHostDb();
|
|
375
400
|
// Reconcile a durable retention-off setting before recovery/connect can make
|
|
376
401
|
// crash-left owner-facing terminal history observable again. Correctness
|