@runuai/host 0.9.145 → 0.9.147
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/agent-cli.ts +17 -2
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar.ts +12 -1
- package/lib/stopped-task-status-queue.ts +24 -6
- package/package.json +1 -1
- package/src/index.ts +11 -0
- package/src/main.ts +48 -9
- package/src/protocol.ts +27 -5
package/lib/agent-cli.ts
CHANGED
|
@@ -305,7 +305,22 @@ async function main() {
|
|
|
305
305
|
case "memory search": {
|
|
306
306
|
const q = pos.join(" ") || flags.q || "";
|
|
307
307
|
const author = flags.as ? "&author=" + encodeURIComponent(flags.as) : "";
|
|
308
|
-
|
|
308
|
+
const limit = flags.limit ? "&limit=" + encodeURIComponent(String(flags.limit)) : "";
|
|
309
|
+
const res = await api(
|
|
310
|
+
"GET",
|
|
311
|
+
"/api/agent/memory?q=" + encodeURIComponent(q) + author + limit,
|
|
312
|
+
);
|
|
313
|
+
out(res.memories);
|
|
314
|
+
// Truncation goes to stderr so it cannot corrupt piped JSON, and it is
|
|
315
|
+
// stated rather than implied: recalling across tasks means reading the
|
|
316
|
+
// WHOLE store, and a short page that says nothing reads as all there is.
|
|
317
|
+
// NOTE: this file is a template literal. No backticks, no \${} in here.
|
|
318
|
+
if (res.truncated) {
|
|
319
|
+
console.error(
|
|
320
|
+
"uai: showing " + res.memories.length + " of " + res.total +
|
|
321
|
+
" — re-run with --limit all for every row.",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
309
324
|
break;
|
|
310
325
|
}
|
|
311
326
|
case "memory save": {
|
|
@@ -453,7 +468,7 @@ async function main() {
|
|
|
453
468
|
" uai task list",
|
|
454
469
|
" uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
|
|
455
470
|
' uai task close --status finished|canceled [--reason "final report"]',
|
|
456
|
-
" uai memory search <query>",
|
|
471
|
+
" uai memory search <query> [--as handle] [--limit N|all]",
|
|
457
472
|
" uai memory save <text> [--project id] [--tags a,b]",
|
|
458
473
|
" uai memory delete <id>",
|
|
459
474
|
" uai todo list",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warm-pool claim's host half (docs/machine-warm-pool.md): the cloud's
|
|
3
|
+
* `sidecarAssign` names the task this pool machine now serves. The task id
|
|
4
|
+
* is appended to the sidecar unit's EnvironmentFile and the process exits
|
|
5
|
+
* with ASSIGN_RESTART_EXIT_CODE — systemd (`Restart=on-failure`) brings the
|
|
6
|
+
* unit back up in plain sidecar mode, the tested runner-restart path.
|
|
7
|
+
* Rewriting a root-owned file and restarting is deliberately boring: no
|
|
8
|
+
* runtime re-wiring of boot-time gates, no half-pool half-task states.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
import { sidecarPoolMode } from "./sidecar";
|
|
13
|
+
|
|
14
|
+
/** Anything but 0 and 3: 0 would not restart the unit, 3 is the lease-denied
|
|
15
|
+
* latch (`RestartPreventExitStatus=3`). */
|
|
16
|
+
export const ASSIGN_RESTART_EXIT_CODE = 64;
|
|
17
|
+
const EXIT_DELAY_MS = 500;
|
|
18
|
+
|
|
19
|
+
export function sidecarEnvFile(): string {
|
|
20
|
+
return process.env.UAI_SIDECAR_ENV_FILE ?? "/etc/uai/sidecar.env";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function applySidecarAssign(
|
|
24
|
+
taskId: string,
|
|
25
|
+
deps: {
|
|
26
|
+
envFile?: string;
|
|
27
|
+
exit?: (code: number) => void;
|
|
28
|
+
append?: (file: string, text: string) => void;
|
|
29
|
+
read?: (file: string) => string;
|
|
30
|
+
} = {},
|
|
31
|
+
): void {
|
|
32
|
+
if (!sidecarPoolMode()) {
|
|
33
|
+
throw new Error("not a pool machine (already assigned, or not a sidecar)");
|
|
34
|
+
}
|
|
35
|
+
if (!/^[a-z0-9]{1,32}$/i.test(taskId)) throw new Error("invalid task id");
|
|
36
|
+
const file = deps.envFile ?? sidecarEnvFile();
|
|
37
|
+
const read = deps.read ?? ((f: string) => readFileSync(f, "utf8"));
|
|
38
|
+
const current = read(file);
|
|
39
|
+
if (/^UAI_SIDECAR_TASK=/m.test(current)) {
|
|
40
|
+
throw new Error("this machine already carries a task assignment");
|
|
41
|
+
}
|
|
42
|
+
const append = deps.append ?? ((f: string, text: string) => appendFileSync(f, text));
|
|
43
|
+
append(file, `UAI_SIDECAR_TASK=${taskId}\n`);
|
|
44
|
+
// Ack first, then die: the delay lets the command reply flush to the
|
|
45
|
+
// bridge before the socket goes with the process.
|
|
46
|
+
const exit = deps.exit ?? ((code: number) => process.exit(code));
|
|
47
|
+
const timer = setTimeout(() => exit(ASSIGN_RESTART_EXIT_CODE), EXIT_DELAY_MS);
|
|
48
|
+
timer.unref?.();
|
|
49
|
+
}
|
package/lib/sidecar.ts
CHANGED
|
@@ -13,8 +13,19 @@ export function sidecarTaskId(): string | null {
|
|
|
13
13
|
return value && /^[a-z0-9]{1,32}$/i.test(value) ? value : null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Warm-pool mode (docs/machine-warm-pool.md): a sidecar with no task yet.
|
|
18
|
+
* Same machine gates as sidecar mode, no lease (nothing to scope one to);
|
|
19
|
+
* the process waits for `sidecarAssign` to write `UAI_SIDECAR_TASK` into
|
|
20
|
+
* the unit env and restart it into plain sidecar mode. Once a task id is
|
|
21
|
+
* present, pool mode is over — even before the restart lands.
|
|
22
|
+
*/
|
|
23
|
+
export function sidecarPoolMode(): boolean {
|
|
24
|
+
return process.env.UAI_SIDECAR_POOL === "1" && sidecarTaskId() === null;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export function sidecarMode(): boolean {
|
|
17
|
-
return sidecarTaskId() !== null;
|
|
28
|
+
return sidecarTaskId() !== null || sidecarPoolMode();
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
/**
|
|
@@ -4,8 +4,21 @@
|
|
|
4
4
|
* mirror remains the authority at flush time so a later Resume wins over an
|
|
5
5
|
* older queued stop.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* Why the host stopped the task, when the host knows.
|
|
9
|
+
*
|
|
10
|
+
* `"manual"` is ADR-028's local-UI Stop — a person at the machine pressed it.
|
|
11
|
+
* Everything else the host reports through this frame is automatic (writable
|
|
12
|
+
* -container quarantine during a runtime activation or a shared-volume
|
|
13
|
+
* repair), and carries no reason. The cloud needs the difference because
|
|
14
|
+
* ADR-144 lets a collaborator resume a task that stopped on its own but not
|
|
15
|
+
* one a human deliberately paused, and until now both arrived as the same
|
|
16
|
+
* three fields.
|
|
17
|
+
*/
|
|
18
|
+
export type StoppedTaskReason = "manual";
|
|
19
|
+
|
|
7
20
|
export class StoppedTaskStatusQueue {
|
|
8
|
-
private readonly pending = new
|
|
21
|
+
private readonly pending = new Map<string, StoppedTaskReason | undefined>();
|
|
9
22
|
|
|
10
23
|
constructor(
|
|
11
24
|
private readonly statusForTask: (
|
|
@@ -13,8 +26,13 @@ export class StoppedTaskStatusQueue {
|
|
|
13
26
|
) => string | null | undefined,
|
|
14
27
|
) {}
|
|
15
28
|
|
|
16
|
-
enqueue(taskId: string): void {
|
|
17
|
-
|
|
29
|
+
enqueue(taskId: string, reason?: StoppedTaskReason): void {
|
|
30
|
+
// A reason, once known, STICKS across a re-enqueue. The same task can be
|
|
31
|
+
// queued twice before a flush — an operator Stop, then a quarantine sweep
|
|
32
|
+
// that finds its container already down — and the automatic pass arriving
|
|
33
|
+
// second does not make the human's decision retroactively automatic.
|
|
34
|
+
// Dropping it would silently hand the task back to every collaborator.
|
|
35
|
+
this.pending.set(taskId, reason ?? this.pending.get(taskId));
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
/** A failed taskUp may have stopped a legacy runtime before recreation lost
|
|
@@ -35,9 +53,9 @@ export class StoppedTaskStatusQueue {
|
|
|
35
53
|
return true;
|
|
36
54
|
}
|
|
37
55
|
|
|
38
|
-
flush(publish: (taskId: string) => void): void {
|
|
39
|
-
for (const taskId of [...this.pending]) {
|
|
40
|
-
if (this.statusForTask(taskId) === "stopped") publish(taskId);
|
|
56
|
+
flush(publish: (taskId: string, reason?: StoppedTaskReason) => void): void {
|
|
57
|
+
for (const [taskId, reason] of [...this.pending]) {
|
|
58
|
+
if (this.statusForTask(taskId) === "stopped") publish(taskId, reason);
|
|
41
59
|
this.pending.delete(taskId);
|
|
42
60
|
}
|
|
43
61
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1459,6 +1459,17 @@ export const hostCommands: HostCommands = {
|
|
|
1459
1459
|
}
|
|
1460
1460
|
},
|
|
1461
1461
|
|
|
1462
|
+
async sidecarAssign(ctx, input) {
|
|
1463
|
+
logCommand(ctx, "sidecarAssign", input.taskId);
|
|
1464
|
+
try {
|
|
1465
|
+
const { applySidecarAssign } = await import("../lib/sidecar-assign");
|
|
1466
|
+
applySidecarAssign(input.taskId);
|
|
1467
|
+
return ok(undefined);
|
|
1468
|
+
} catch (err) {
|
|
1469
|
+
return failFromUnknown(err);
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
|
|
1462
1473
|
async channelInterrupt(ctx, taskId, agentId) {
|
|
1463
1474
|
logCommand(ctx, "channelInterrupt", taskId, agentId);
|
|
1464
1475
|
try {
|
package/src/main.ts
CHANGED
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
getHostTask,
|
|
29
29
|
hasInFlightHostTaskLifecycle,
|
|
30
30
|
} from "../lib/runtime-state";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
StoppedTaskStatusQueue,
|
|
33
|
+
type StoppedTaskReason,
|
|
34
|
+
} from "../lib/stopped-task-status-queue";
|
|
32
35
|
import {
|
|
33
36
|
getOrchestrator,
|
|
34
37
|
quarantineWritableRuntimeContainers,
|
|
@@ -257,7 +260,7 @@ import {
|
|
|
257
260
|
} from "./protocol";
|
|
258
261
|
import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
|
|
259
262
|
import { LeaseManager } from "../lib/lease";
|
|
260
|
-
import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
|
|
263
|
+
import { markSidecarReady, sidecarMode, sidecarPoolMode, sidecarTaskId } from "../lib/sidecar";
|
|
261
264
|
import {
|
|
262
265
|
purgeDefaultEngineSlots,
|
|
263
266
|
SIGN_IN_BOX_TASK_REFUSAL,
|
|
@@ -638,7 +641,9 @@ if (signInBoxMode()) {
|
|
|
638
641
|
* reconciles and the S3 file mounts run. A denial means the task is no
|
|
639
642
|
* longer ours to run — exit 3 and let the cloud's sweep reclaim the machine.
|
|
640
643
|
*/
|
|
641
|
-
|
|
644
|
+
// A POOL-mode sidecar requests no lease: it has no task to scope one to,
|
|
645
|
+
// and the cloud would refuse it anyway (docs/machine-warm-pool.md).
|
|
646
|
+
const leaseManager: LeaseManager | null = sidecarTaskId()
|
|
642
647
|
? new LeaseManager({
|
|
643
648
|
taskId: sidecarTaskId()!,
|
|
644
649
|
send: (frame) => {
|
|
@@ -864,7 +869,11 @@ async function startLocalUi(): Promise<void> {
|
|
|
864
869
|
invalidateMachineTunnelRoute(taskId);
|
|
865
870
|
try {
|
|
866
871
|
const result = await getOrchestrator().stopTask(taskId);
|
|
867
|
-
|
|
872
|
+
// A PERSON pressed Stop on this machine's own UI — ADR-028. Labelled
|
|
873
|
+
// as such because the cloud otherwise cannot tell it apart from the
|
|
874
|
+
// quarantine sweeps below, which reach the bridge through this same
|
|
875
|
+
// frame, and ADR-144 turns that difference into who may undo it.
|
|
876
|
+
if (result.ok) queueStoppedTaskStatus(taskId, "manual");
|
|
868
877
|
return result;
|
|
869
878
|
} finally {
|
|
870
879
|
invalidateMachineTunnelRoute(taskId);
|
|
@@ -931,7 +940,11 @@ function buildCapabilities(): HostCapabilities {
|
|
|
931
940
|
},
|
|
932
941
|
}
|
|
933
942
|
: {}),
|
|
934
|
-
...(sidecarTaskId()
|
|
943
|
+
...(sidecarTaskId()
|
|
944
|
+
? { sidecar: { taskId: sidecarTaskId()! } }
|
|
945
|
+
: sidecarPoolMode()
|
|
946
|
+
? { sidecar: { pool: true } }
|
|
947
|
+
: {}),
|
|
935
948
|
maintenanceReady: areAgentClisReady(),
|
|
936
949
|
mcpGateway: mcpGateway.state(),
|
|
937
950
|
engineLogins: engineLoginManager.capabilities(),
|
|
@@ -1036,15 +1049,18 @@ function sendCapabilities(): void {
|
|
|
1036
1049
|
|
|
1037
1050
|
/** Keep a rolling-isolation/local-UI stop from remaining cloud-ghost-running
|
|
1038
1051
|
* merely because the bridge was between connections at the transition. */
|
|
1039
|
-
function queueStoppedTaskStatus(
|
|
1040
|
-
|
|
1052
|
+
function queueStoppedTaskStatus(
|
|
1053
|
+
taskId: string,
|
|
1054
|
+
reason?: StoppedTaskReason,
|
|
1055
|
+
): void {
|
|
1056
|
+
pendingRuntimeIsolationStatuses.enqueue(taskId, reason);
|
|
1041
1057
|
flushStoppedTaskStatuses();
|
|
1042
1058
|
}
|
|
1043
1059
|
|
|
1044
1060
|
function flushStoppedTaskStatuses(socket: WebSocket | null = ws): void {
|
|
1045
1061
|
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
1046
|
-
pendingRuntimeIsolationStatuses.flush((taskId) => {
|
|
1047
|
-
send(socket, { kind: "task.status", taskId, status: "stopped" });
|
|
1062
|
+
pendingRuntimeIsolationStatuses.flush((taskId, reason) => {
|
|
1063
|
+
send(socket, { kind: "task.status", taskId, status: "stopped", reason });
|
|
1048
1064
|
});
|
|
1049
1065
|
}
|
|
1050
1066
|
|
|
@@ -2777,6 +2793,8 @@ async function dispatchCommand(
|
|
|
2777
2793
|
);
|
|
2778
2794
|
case "filesOp":
|
|
2779
2795
|
return hostCommands.filesOp(ctx, expectFilesOpInput(args, 0));
|
|
2796
|
+
case "sidecarAssign":
|
|
2797
|
+
return hostCommands.sidecarAssign(ctx, expectSidecarAssignInput(args, 0));
|
|
2780
2798
|
case "channelInterrupt":
|
|
2781
2799
|
return hostCommands.channelInterrupt(
|
|
2782
2800
|
ctx,
|
|
@@ -2910,6 +2928,26 @@ function expectEngineAccountRemoveInput(
|
|
|
2910
2928
|
};
|
|
2911
2929
|
}
|
|
2912
2930
|
|
|
2931
|
+
function expectSidecarAssignInput(
|
|
2932
|
+
args: unknown[],
|
|
2933
|
+
index: number,
|
|
2934
|
+
): { hostId: string; taskId: string } {
|
|
2935
|
+
const value = args[index];
|
|
2936
|
+
const hostId = (value as { hostId?: unknown } | null)?.hostId;
|
|
2937
|
+
const taskId = (value as { taskId?: unknown } | null)?.taskId;
|
|
2938
|
+
if (
|
|
2939
|
+
!value ||
|
|
2940
|
+
typeof value !== "object" ||
|
|
2941
|
+
typeof hostId !== "string" ||
|
|
2942
|
+
hostId.length === 0 ||
|
|
2943
|
+
typeof taskId !== "string" ||
|
|
2944
|
+
!/^[a-z0-9]{1,32}$/i.test(taskId)
|
|
2945
|
+
) {
|
|
2946
|
+
throw new Error(`invalid command args: expected sidecar assignment at ${index}`);
|
|
2947
|
+
}
|
|
2948
|
+
return { hostId, taskId };
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2913
2951
|
function expectNumberArg(args: unknown[], index: number): number {
|
|
2914
2952
|
const value = args[index];
|
|
2915
2953
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
@@ -3505,6 +3543,7 @@ const HOST_COMMAND_NAMES = {
|
|
|
3505
3543
|
attachmentWrite: true,
|
|
3506
3544
|
attachmentRead: true,
|
|
3507
3545
|
filesOp: true,
|
|
3546
|
+
sidecarAssign: true,
|
|
3508
3547
|
channelInterrupt: true,
|
|
3509
3548
|
appendTranscript: true,
|
|
3510
3549
|
previewEnsure: true,
|
package/src/protocol.ts
CHANGED
|
@@ -1075,8 +1075,10 @@ export interface HostCapabilities {
|
|
|
1075
1075
|
* provider/region on the host page and, eventually, per-task backing
|
|
1076
1076
|
* choice keys off it. */
|
|
1077
1077
|
/** ADR-133 sidecar mode: this host IS the task's machine — exactly one
|
|
1078
|
-
* task, no container runtime, tunnels on loopback.
|
|
1079
|
-
|
|
1078
|
+
* task, no container runtime, tunnels on loopback. A warm-pool machine
|
|
1079
|
+
* (docs/machine-warm-pool.md) advertises `pool` instead of a task until
|
|
1080
|
+
* `sidecarAssign` restarts it into task mode. */
|
|
1081
|
+
sidecar?: { taskId?: string; pool?: boolean };
|
|
1080
1082
|
machineTasks?: {
|
|
1081
1083
|
provider: string;
|
|
1082
1084
|
region?: string;
|
|
@@ -1550,6 +1552,14 @@ export interface HostCommands {
|
|
|
1550
1552
|
ctx: CommandContext,
|
|
1551
1553
|
input: FilesOpInput,
|
|
1552
1554
|
): Promise<HostCommandResult<FilesOpValue>>;
|
|
1555
|
+
/** Warm pool claim (docs/machine-warm-pool.md): tell a POOL-mode sidecar
|
|
1556
|
+
* which task it now serves. The sidecar writes `UAI_SIDECAR_TASK` into
|
|
1557
|
+
* its unit env, acks, and restarts into plain sidecar mode; a runner not
|
|
1558
|
+
* in pool mode refuses. */
|
|
1559
|
+
sidecarAssign(
|
|
1560
|
+
ctx: CommandContext,
|
|
1561
|
+
input: { hostId: string; taskId: string },
|
|
1562
|
+
): Promise<HostCommandResult<void>>;
|
|
1553
1563
|
channelInterrupt(
|
|
1554
1564
|
ctx: CommandContext,
|
|
1555
1565
|
taskId: string,
|
|
@@ -1802,9 +1812,21 @@ export type HostToCloud =
|
|
|
1802
1812
|
// ADR-103: `seq` is the outbox sequence number (monotonic within a bootId).
|
|
1803
1813
|
// Absent from pre-103 hosts; the cloud dedups replay overlap by it.
|
|
1804
1814
|
| { kind: "event"; event: HostEvent; seq?: number }
|
|
1805
|
-
// Host-pushed task lifecycle
|
|
1806
|
-
//
|
|
1807
|
-
|
|
1815
|
+
// Host-pushed task lifecycle: the cloud mirrors the status (currently
|
|
1816
|
+
// "stopped"). TWO different events arrive through this one frame — ADR-028's
|
|
1817
|
+
// local-UI Stop, where the host operator paused the task's containers, and
|
|
1818
|
+
// the automatic writable-container quarantine a runtime activation or
|
|
1819
|
+
// shared-volume repair performs. `reason: "manual"` is the first; its
|
|
1820
|
+
// absence is the second.
|
|
1821
|
+
//
|
|
1822
|
+
// The cloud cannot infer it. ADR-144 lets a collaborator resume a task that
|
|
1823
|
+
// stopped on its own and not one a human deliberately paused, and with both
|
|
1824
|
+
// stops indistinguishable on the wire the deliberate one was reversible by
|
|
1825
|
+
// anyone on the task. OPTIONAL so an older host stays compatible: it sends
|
|
1826
|
+
// no reason, the cloud records none, and that host's operator Stop keeps the
|
|
1827
|
+
// pre-ADR-144 behaviour rather than mislabelling its quarantines as manual
|
|
1828
|
+
// and blocking the recovery path they exist to enable.
|
|
1829
|
+
| { kind: "task.status"; taskId: string; status: string; reason?: string }
|
|
1808
1830
|
| { kind: "ping"; ts: number }
|
|
1809
1831
|
// ADR-133: a sidecar asks for (or refreshes) its task-scoped AWS lease.
|
|
1810
1832
|
| { kind: "lease.request"; opId: string; taskId: string }
|