@runuai/host 0.9.86 → 0.9.88
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/agents/machine-durable.ts +61 -3
- package/lib/orchestrator.ts +15 -0
- package/package.json +1 -1
- package/src/index.ts +36 -12
- package/src/main.ts +8 -0
- package/src/protocol.ts +22 -0
|
@@ -143,6 +143,8 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
143
143
|
#closed = false;
|
|
144
144
|
#detached = false;
|
|
145
145
|
#sawSpawnMeta = false;
|
|
146
|
+
/** Completed-and-empty direct outbox probes past the spawn grace. */
|
|
147
|
+
#noMetaStrikes = 0;
|
|
146
148
|
readonly #startedAt = Date.now();
|
|
147
149
|
#tail: TaskEnvironmentProcess | null = null;
|
|
148
150
|
#tailGeneration = 0;
|
|
@@ -322,9 +324,40 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
322
324
|
this.#probing = true;
|
|
323
325
|
try {
|
|
324
326
|
if (!this.#sawSpawnMeta) {
|
|
325
|
-
if (Date.now() - this.#startedAt
|
|
326
|
-
|
|
327
|
+
if (Date.now() - this.#startedAt <= HEARTBEAT_STALE_MS) return;
|
|
328
|
+
// Grace elapsed on OUR clock — but the spawn meta arrives via the
|
|
329
|
+
// tail, an ssh child of THIS host, and both starve together when the
|
|
330
|
+
// host CPU is pegged (live 2026-08-29: a 12-task launch burst pushed
|
|
331
|
+
// load to 16, tails lagged, and healthy runners were declared dead —
|
|
332
|
+
// then respawned, ORPHANING the originals). Before any verdict, ask
|
|
333
|
+
// the machine directly: the runner writes its spawn meta as the
|
|
334
|
+
// outbox's first line.
|
|
335
|
+
let sawOnMachine = false;
|
|
336
|
+
try {
|
|
337
|
+
const probe = await execStep(this.#environment, [
|
|
338
|
+
"/bin/sh",
|
|
339
|
+
"-c",
|
|
340
|
+
'head -n1 "$1/outbox.jsonl" 2>/dev/null | grep -c __uai',
|
|
341
|
+
"probe",
|
|
342
|
+
this.#sessionDir,
|
|
343
|
+
]);
|
|
344
|
+
sawOnMachine =
|
|
345
|
+
probe.exitCode === 0 && Number(probe.stdout.trim()) > 0;
|
|
346
|
+
} catch {
|
|
347
|
+
// Unreachable machine: counts as a strike below.
|
|
348
|
+
}
|
|
349
|
+
if (this.#closed || this.#detached) return;
|
|
350
|
+
if (sawOnMachine) {
|
|
351
|
+
this.#sawSpawnMeta = true;
|
|
352
|
+
return;
|
|
327
353
|
}
|
|
354
|
+
// Two COMPLETED empty probes past the grace — not elapsed time — are
|
|
355
|
+
// the death sentence; probes that never ran prove nothing.
|
|
356
|
+
this.#noMetaStrikes += 1;
|
|
357
|
+
if (this.#noMetaStrikes < 2) return;
|
|
358
|
+
await this.#killRemoteRunner();
|
|
359
|
+
if (this.#closed || this.#detached) return;
|
|
360
|
+
this.#finish(null, "no spawn meta within grace");
|
|
328
361
|
return;
|
|
329
362
|
}
|
|
330
363
|
const fresh = await machineHeartbeatFresh(
|
|
@@ -333,12 +366,37 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
333
366
|
HEARTBEAT_STALE_MS,
|
|
334
367
|
);
|
|
335
368
|
if (this.#closed || this.#detached) return;
|
|
336
|
-
if (!fresh)
|
|
369
|
+
if (!fresh) {
|
|
370
|
+
// A stale-but-alive runner would be doubled by the respawn; the
|
|
371
|
+
// single-runner-per-session invariant is enforced here, not hoped for.
|
|
372
|
+
await this.#killRemoteRunner();
|
|
373
|
+
if (this.#closed || this.#detached) return;
|
|
374
|
+
this.#finish(null, "heartbeat stale");
|
|
375
|
+
}
|
|
337
376
|
} finally {
|
|
338
377
|
this.#probing = false;
|
|
339
378
|
}
|
|
340
379
|
}
|
|
341
380
|
|
|
381
|
+
/** Best-effort remote kill of this session's runner before declaring it
|
|
382
|
+
* dead: a wrong verdict must cost a restart, never an orphaned agent.
|
|
383
|
+
* `runner[.]mjs` so the kill-shell's own argv (which carries this script
|
|
384
|
+
* text) can never self-match — the pgrep/pkill self-match class. */
|
|
385
|
+
async #killRemoteRunner(): Promise<void> {
|
|
386
|
+
try {
|
|
387
|
+
await execStep(this.#environment, [
|
|
388
|
+
"/bin/sh",
|
|
389
|
+
"-c",
|
|
390
|
+
'pkill -f "runner[.]mjs $1" >/dev/null 2>&1; true',
|
|
391
|
+
"kill",
|
|
392
|
+
this.#sessionDir,
|
|
393
|
+
]);
|
|
394
|
+
} catch {
|
|
395
|
+
// The finish verdict stands either way; the orphan sweep in recovery
|
|
396
|
+
// remains the backstop for an unreachable machine.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
342
400
|
#finish(code: number | null, reason = "unspecified"): void {
|
|
343
401
|
if (this.#closed) return;
|
|
344
402
|
// Session deaths are load-bearing and were undiagnosable without this
|
package/lib/orchestrator.ts
CHANGED
|
@@ -1549,6 +1549,7 @@ export class Orchestrator {
|
|
|
1549
1549
|
// can consume/reorder prompt state while the earlier error still owns it.
|
|
1550
1550
|
let eventTail: Promise<void> = Promise.resolve();
|
|
1551
1551
|
session.onEvent((event) => {
|
|
1552
|
+
this.markChannelActivity(channel.taskId);
|
|
1552
1553
|
// Recycle/teardown deletes the old session before calling close().
|
|
1553
1554
|
// Adapters deliberately emit a final exit event; without this identity
|
|
1554
1555
|
// guard it can delete a replacement or consume its respawn budget.
|
|
@@ -3001,6 +3002,20 @@ export class Orchestrator {
|
|
|
3001
3002
|
* reconciles it to `stopped` (worktree present) and it can be resumed. Since
|
|
3002
3003
|
* `stopped` is not an ACTIVE status, host recovery won't restart it.
|
|
3003
3004
|
*/
|
|
3005
|
+
/** Last observed sign of life per channel: any session event (agent
|
|
3006
|
+
* output, turn boundaries) or an inbound delivery. The idle-stop veto
|
|
3007
|
+
* (ADR-125) reads this — the cloud decides idleness from what it can see,
|
|
3008
|
+
* but only the host can see a turn that is mid-flight RIGHT NOW. */
|
|
3009
|
+
readonly #channelActivity = new Map<string, number>();
|
|
3010
|
+
|
|
3011
|
+
markChannelActivity(taskId: string): void {
|
|
3012
|
+
this.#channelActivity.set(taskId, Date.now());
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
lastChannelActivity(taskId: string): number | null {
|
|
3016
|
+
return this.#channelActivity.get(taskId) ?? null;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3004
3019
|
async stopTask(taskId: string): Promise<{ ok: boolean; error?: string }> {
|
|
3005
3020
|
return this.runTaskLifecycle(taskId, () =>
|
|
3006
3021
|
this.stopTaskWithinLifecycle(taskId),
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -976,18 +976,13 @@ export const hostCommands: HostCommands = {
|
|
|
976
976
|
}
|
|
977
977
|
const result = await wrapAgent(ctx, "taskDown", async () => {
|
|
978
978
|
if (environment === null) return agent.taskDown(input);
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
) {
|
|
987
|
-
stopMachineGatewayTunnel(input.taskId);
|
|
988
|
-
await environment.stop();
|
|
989
|
-
return taskDownResultForInput(input, { status: "stopped" });
|
|
990
|
-
}
|
|
979
|
+
// taskDown is TERMINAL — its only cloud callers are task close and
|
|
980
|
+
// failed-occurrence/orphan cleanup; pause rides the separate stopTask
|
|
981
|
+
// command. Machines therefore TERMINATE here: a closed task can never
|
|
982
|
+
// be resumed, so a stopped-but-kept machine is a per-month disk leak
|
|
983
|
+
// with no consumer (owner, 2026-08-29 — 16 parked disks after one
|
|
984
|
+
// evening of testing). The task branch lives on the remote; the
|
|
985
|
+
// machine's disk is not a preservation surface once the task ends.
|
|
991
986
|
if (environment.descriptor.locator.provider === "machine") {
|
|
992
987
|
stopMachineGatewayTunnel(input.taskId);
|
|
993
988
|
}
|
|
@@ -1034,6 +1029,28 @@ export const hostCommands: HostCommands = {
|
|
|
1034
1029
|
});
|
|
1035
1030
|
},
|
|
1036
1031
|
|
|
1032
|
+
async taskStop(ctx, taskId, input) {
|
|
1033
|
+
logCommand(ctx, "taskStop", taskId, input.reason);
|
|
1034
|
+
// ADR-125: idle stops are the cloud's judgment from what it can see;
|
|
1035
|
+
// only the host can see a turn that is mid-flight RIGHT NOW. Manual
|
|
1036
|
+
// pause carries no veto — a human's explicit gesture wins.
|
|
1037
|
+
if (input.reason === "idle") {
|
|
1038
|
+
const activeAt = getOrchestrator().lastChannelActivity(taskId);
|
|
1039
|
+
if (activeAt !== null && Date.now() - activeAt < IDLE_STOP_VETO_MS) {
|
|
1040
|
+
return { ok: true, value: { vetoed: "active" as const } };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const result = await getOrchestrator().stopTask(taskId);
|
|
1044
|
+
if (!result.ok) {
|
|
1045
|
+
return {
|
|
1046
|
+
ok: false,
|
|
1047
|
+
code: HostErrorCode.Internal,
|
|
1048
|
+
message: result.error ?? "stop failed",
|
|
1049
|
+
retryable: true,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
return { ok: true, value: {} };
|
|
1053
|
+
},
|
|
1037
1054
|
async taskStatus(ctx, taskId) {
|
|
1038
1055
|
logCommand(ctx, "taskStatus", taskId);
|
|
1039
1056
|
const runtimeFailure = runtimeUnavailable();
|
|
@@ -1117,6 +1134,9 @@ export const hostCommands: HostCommands = {
|
|
|
1117
1134
|
const runtimeFailure = runtimeUnavailable();
|
|
1118
1135
|
if (runtimeFailure) return runtimeFailure;
|
|
1119
1136
|
try {
|
|
1137
|
+
// An inbound turn is activity: the idle veto must cover the gap between
|
|
1138
|
+
// this delivery landing and the agent's first output event.
|
|
1139
|
+
getOrchestrator().markChannelActivity(taskId);
|
|
1120
1140
|
const result = await getOrchestrator().deliver(taskId, agentId, text);
|
|
1121
1141
|
if (!result.ok) {
|
|
1122
1142
|
return {
|
|
@@ -1537,6 +1557,10 @@ function teardownRuntimeUnavailable(): HostCommandResult<never> | null {
|
|
|
1537
1557
|
};
|
|
1538
1558
|
}
|
|
1539
1559
|
|
|
1560
|
+
/** ADR-125: an idle stop is vetoed when the channel showed any activity
|
|
1561
|
+
* this recently — a delivery landing or any session event. */
|
|
1562
|
+
const IDLE_STOP_VETO_MS = 120_000;
|
|
1563
|
+
|
|
1540
1564
|
const RUNTIME_TASK_ADMISSION_WAIT_MS = 3_000;
|
|
1541
1565
|
|
|
1542
1566
|
/**
|
package/src/main.ts
CHANGED
|
@@ -173,6 +173,7 @@ import {
|
|
|
173
173
|
HOST_MAINTENANCE_READINESS_PROTOCOL_FEATURE,
|
|
174
174
|
HOST_CONFIG_PROTOCOL_FEATURE,
|
|
175
175
|
HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE,
|
|
176
|
+
HOST_TASK_STOP_PROTOCOL_FEATURE,
|
|
176
177
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
177
178
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
178
179
|
HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
|
|
@@ -584,6 +585,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
584
585
|
HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
|
|
585
586
|
HOST_CONFIG_PROTOCOL_FEATURE,
|
|
586
587
|
HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE,
|
|
588
|
+
HOST_TASK_STOP_PROTOCOL_FEATURE,
|
|
587
589
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
588
590
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
589
591
|
// The echo adapter cannot execute the in-task CLI. Advertising typed
|
|
@@ -2279,6 +2281,12 @@ async function dispatchCommand(
|
|
|
2279
2281
|
const downInput = expectTaskDownInput(args, 0);
|
|
2280
2282
|
return hostCommands.taskDown(ctx, downInput);
|
|
2281
2283
|
}
|
|
2284
|
+
case "taskStop":
|
|
2285
|
+
return hostCommands.taskStop(
|
|
2286
|
+
ctx,
|
|
2287
|
+
typeof args[0] === "string" ? args[0] : "",
|
|
2288
|
+
(args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
|
|
2289
|
+
);
|
|
2282
2290
|
case "taskStatus":
|
|
2283
2291
|
return hostCommands.taskStatus(ctx, expectString(args, 0));
|
|
2284
2292
|
case "channelEnsure":
|
package/src/protocol.ts
CHANGED
|
@@ -89,6 +89,10 @@ export const HOST_CONFIG_PROTOCOL_FEATURE = "host-config-v1";
|
|
|
89
89
|
/** Remote update/restart over the bridge (owner-gated cloud routes). */
|
|
90
90
|
export const HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE =
|
|
91
91
|
"host-remote-lifecycle-v1";
|
|
92
|
+
/** ADR-125: cloud-initiated resumable task stop (idle auto-stop + manual
|
|
93
|
+
* pause). Idle stops carry reason "idle" and the host may VETO them when a
|
|
94
|
+
* turn is active right now — the veto is an ok result, not an error. */
|
|
95
|
+
export const HOST_TASK_STOP_PROTOCOL_FEATURE = "host-task-stop-v1";
|
|
92
96
|
/** ADR-116: labeled engine accounts on the cloud host page — account list in
|
|
93
97
|
* capabilities, account-targeted login (`label` on engine.login.start), and
|
|
94
98
|
* the engineAccountRemove command. */
|
|
@@ -1111,6 +1115,19 @@ export interface TaskLaunchInput {
|
|
|
1111
1115
|
ownerName?: string;
|
|
1112
1116
|
}
|
|
1113
1117
|
|
|
1118
|
+
/** ADR-125 resumable stop. "idle" = the cloud's sweep decided nobody is
|
|
1119
|
+
* using the task; the host may veto if a turn is live. "manual" = a human
|
|
1120
|
+
* pressed pause; no veto. */
|
|
1121
|
+
export interface TaskStopInput {
|
|
1122
|
+
reason: "idle" | "manual";
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export interface TaskStopResult {
|
|
1126
|
+
/** Present when the host declined an idle stop because the channel showed
|
|
1127
|
+
* activity within its veto window. The cloud retries on a later sweep. */
|
|
1128
|
+
vetoed?: "active";
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1114
1131
|
export interface TaskDownInput {
|
|
1115
1132
|
taskId: string;
|
|
1116
1133
|
task: TaskCommandTask;
|
|
@@ -1296,6 +1313,11 @@ export interface HostCommands {
|
|
|
1296
1313
|
ctx: CommandContext,
|
|
1297
1314
|
taskId: string,
|
|
1298
1315
|
): Promise<HostCommandResult<TaskStatusResult>>;
|
|
1316
|
+
taskStop(
|
|
1317
|
+
ctx: CommandContext,
|
|
1318
|
+
taskId: string,
|
|
1319
|
+
input: TaskStopInput,
|
|
1320
|
+
): Promise<HostCommandResult<TaskStopResult>>;
|
|
1299
1321
|
channelEnsure(
|
|
1300
1322
|
ctx: CommandContext,
|
|
1301
1323
|
input: ChannelEnsureInput,
|