Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
be96323
test: operator calls worker.update on the gateway and the worker's vi…
V3RON Oct 22, 2026
68798d6
feat(gateway): worker.update sends daemon.update to the worker and sh…
V3RON Oct 22, 2026
e31c323
test: worker.update on a disconnected or unknown worker is refused (#…
V3RON Oct 22, 2026
4d1efa3
feat(gateway): worker.update refuses an unknown or disconnected worke…
V3RON Oct 22, 2026
8e90ddc
test(contract): worker.update in the role and effect tables (#838)
V3RON Oct 22, 2026
e883716
test: a tick reads an incompatible worker's update with daemon.update…
V3RON Oct 22, 2026
a11b9e7
feat(gateway): an incompatible worker's refresh reads its update with…
V3RON Oct 22, 2026
90f3ff9
test: a failed update query keeps an incompatible worker's last view …
V3RON Oct 22, 2026
3d305f9
test: the gateway skips an updating worker and the request waits unti…
V3RON Oct 22, 2026
0febb69
feat(gateway): routing skips an updating worker and keeps the request…
V3RON Oct 22, 2026
de4d38a
test: a worker's update fields reach its view and clear with the next…
V3RON Oct 22, 2026
4f509e9
feat(gateway): a worker's view carries its update, last failure and l…
V3RON Oct 22, 2026
4aa74f5
test: a worker's daemon.update events re-read its view at once (#838)
V3RON Oct 22, 2026
7cb5a06
feat(gateway): daemon.update events make a gateway read the worker's …
V3RON Oct 22, 2026
aa3b427
test: a DAEMON_UPDATING answer to a dispatched request is handled as …
V3RON Oct 22, 2026
e1f8450
feat(gateway): a DAEMON_UPDATING refusal re-queues the request like N…
V3RON Oct 22, 2026
c9f58b9
feat(gateway): a DAEMON_UPDATING refusal re-queues the request like N…
V3RON Oct 22, 2026
34fa696
test: an operator-drained worker stays drained through its update (#838)
V3RON Oct 22, 2026
de69942
test: POST /v1/workers/{id}/update answers 202, 200 and 403 (#838)
V3RON Oct 22, 2026
9176731
feat(http): POST /v1/workers/{id}/update (#838)
V3RON Oct 22, 2026
3e86d6f
test: worker.update gives up on a worker that does not answer within …
V3RON Oct 22, 2026
566e100
feat(gateway): worker.update is bounded at 90 s on the uplink (#838)
V3RON Oct 22, 2026
18dcc2e
test: worker.update reaches an incompatible worker with the frozen da…
V3RON Oct 22, 2026
a8bbbdf
test: the protocol version is 57 (#838)
V3RON Oct 22, 2026
480bd3e
feat(core): protocol 57 for worker.update and the worker view's updat…
V3RON Oct 22, 2026
0d9d385
feat(client): updateWorker asks a gateway to update one worker (#838)
V3RON Oct 22, 2026
830f083
test: daemon.update on a gateway installs at once with fleet leases h…
V3RON Oct 22, 2026
4a91b10
feat(daemon): a gateway updates itself with no lease wait (#838)
V3RON Oct 22, 2026
6c54b9e
test: a failed install shows in the gateway's status.get (#838)
V3RON Oct 22, 2026
9f45e7b
test: a rolled-back gateway reports the failure at start (#838)
V3RON Oct 22, 2026
8ce88da
feat(daemon): a gateway reads the update result file at start (#838)
V3RON Oct 22, 2026
3875d6d
feat(cli): worker list shows a worker's version, the latest when newe…
V3RON Oct 22, 2026
046f7e4
test: simlock worker update follows the update on that worker (#838)
V3RON Oct 22, 2026
e125fe6
feat(cli): simlock worker update follows an update on a worker (#838)
V3RON Oct 22, 2026
d445be6
docs: simlock worker update, the 90 s message, WORKER_NOT_CONNECTED a…
V3RON Oct 22, 2026
22302cb
refactor(gateway): one registry method reads a daemon.update answer i…
V3RON Oct 22, 2026
8beb1e6
refactor: split the worker update follow and the refresh timeout coun…
V3RON Oct 22, 2026
7b482f9
refactor: split the worker update follow and the refresh timeout coun…
V3RON Oct 22, 2026
b499f6c
test: close the audit's probe gaps for worker update (#838)
V3RON Oct 22, 2026
e9c26f8
docs: move the worker update paragraph to a flow doc, keep the gatewa…
V3RON Oct 22, 2026
1948466
fix(gateway): worker.update keeps a worker's refusal code and answers…
V3RON Oct 22, 2026
b59ceba
test(gateway): worker.update's refusal arrives as a DispatchError, th…
V3RON Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/internal/flows/worker-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Worker update

Modules: [gateway](../../../src/gateway/AGENTS.md).

`worker.update` sends `daemon.update` over that worker's uplink, bounded at 90 s
(`UPDATE_UPLINK_BOUND_MS`, `src/gateway/dispatcher.ts`; `WORKER_NOT_CONNECTED` at the limit), then
refreshes the view. An unknown id → `UNKNOWN_WORKER`; a disconnected one →
`WORKER_NOT_CONNECTED`. An `incompatible` worker gets the same frozen call.

- A view with `update` set is skipped by routing (`updating`): the request waits, it does
not end `NO_CAPACITY`.
- The update and the drain set are separate: an update never calls `setDrained`, and
`update: null` from the next status clears the view.
- `daemon.update` on the gateway itself goes through the `update` option
(`src/daemon/update-coordinator.ts`): no lease wait, straight to installing.
2 changes: 2 additions & 0 deletions src/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export type {
WorkerListOutput,
WorkerRemoveOutput,
WorkerUndrainOutput,
WorkerUpdateInput,
WorkerUpdateOutput,
WorkerView,
} from "../simlock-client/client.js";
export { isSimlockError, SimlockError } from "../simlock-client/client.js";
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const GATEWAY_REASON_TEXT: Readonly<
"not-ready": () => "Not ready yet",
"runtime-missing": () => "OS not installed",
unhealthy: () => "Not running",
updating: () => "Updating",
"unknown-model": () => "Model not offered",
};

Expand Down
112 changes: 111 additions & 1 deletion src/cli/commands/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const WORKER_USAGE =
"Usage: simlock worker list [--json]\n" +
" simlock worker drain <worker-id>\n" +
" simlock worker undrain <worker-id>\n" +
" simlock worker remove <worker-id>\n";
" simlock worker remove <worker-id>\n" +
" simlock worker update <worker-id> [--version <version>] [--force] [--detach]\n";

async function run(argv: readonly string[], context: CommandContext): Promise<number> {
const { environment, token } = context;
Expand All @@ -28,6 +29,7 @@ async function run(argv: readonly string[], context: CommandContext): Promise<nu
if (command === "drain" || command === "undrain" || command === "remove") {
return await runWorkerAction(command, flagArgs, client, environment);
}
if (command === "update") return await runWorkerUpdate(flagArgs, client, environment);
throw new UsageError(withHelpHint(`Unknown worker command: ${String(command)}`));
} finally {
await client.close();
Expand Down Expand Up @@ -78,4 +80,112 @@ async function runWorkerAction(
return 0;
}

const FOLLOW_POLL_MS = 2_000;

type WorkerView = Awaited<ReturnType<SimlockAdminClient["listWorkers"]>>["workers"][number];

function workerName(view: WorkerView | undefined, workerId: string): string {
return view?.label === undefined ? workerId : `${view.label} (${workerId})`;
}

// why: the follow ends on the worker's own view: its version is the target with no update running, or its last failure names the target.
function followOutcome(
view: WorkerView | undefined,
to: string,
name: string,
): { readonly code: number; readonly text: string } | undefined {
const failure = view?.lastUpdateFailure;
if (failure?.to === to) {
return { code: 1, text: `Update failed (${failure.stage}): ${failure.message}` };
}
if (view?.version === to && view.update == null) {
return { code: 0, text: `${name} is running ${to}` };
}
return undefined;
}

function updateEnds(answer: Awaited<ReturnType<SimlockAdminClient["updateWorker"]>>): {
readonly from: string;
readonly to: string;
} {
return {
from: answer.version,
to: answer.update?.to ?? answer.resolved ?? "",
};
}

async function runWorkerUpdate(
argv: readonly string[],
client: SimlockAdminClient,
environment: CommandContext["environment"],
): Promise<number> {
const values = commandArgs(argv, {
detach: { type: "boolean" },
force: { type: "boolean" },
help: { type: "boolean", short: "h" },
version: { type: "string" },
});
if (values.help) {
environment.stdout.write(
"Usage: simlock worker update <worker-id> [--version <version>] [--force] [--detach]\n",
);
return 0;
}
const workerId = requiredPositional(values.positionals, "worker-id");
const answer = await client.updateWorker({
workerId,
...(typeof values.version === "string" ? { version: values.version } : {}),
...(values.force === true ? { force: true } : {}),
});
const find = async (): Promise<WorkerView | undefined> =>
(await client.listWorkers()).workers.find((worker) => worker.id === workerId);
const first = await find();
const name = workerName(first, workerId);
if (answer.upToDate) {
environment.stdout.write(
`${upToDateMessage(name, answer, typeof values.version === "string" ? values.version : undefined)}\n`,
);
return 0;
}
const { from, to } = updateEnds(answer);
if (values.detach === true) {
environment.stdout.write(
`Updating ${name} ${from} → ${to}. Follow it with simlock worker list.\n`,
);
return 0;
}
return follow({ environment, find, first, name, to });
}

// why: the three reasons a worker is up to date, as `simlock update` tells them apart (update.ts upToDateMessage).
function upToDateMessage(
name: string,
answer: { readonly version: string; readonly resolved?: string | null },
requested: string | undefined,
): string {
if (requested !== undefined) return `${name} is already running ${answer.version}`;
if (answer.resolved === answer.version) {
return `${name} is already on the latest (${answer.version})`;
}
return `${name} is newer than the latest (${answer.resolved}). Use --version to install another version.`;
}

async function follow(options: {
readonly environment: CommandContext["environment"];
readonly find: () => Promise<WorkerView | undefined>;
readonly first: WorkerView | undefined;
readonly name: string;
readonly to: string;
}): Promise<number> {
const { environment, find, name, to } = options;
for (let view = options.first; ; view = await find()) {
const outcome = followOutcome(view, to, name);
if (outcome !== undefined) {
environment.stdout.write(`${outcome.text}\n`);
return outcome.code;
}
await environment.sleep(FOLLOW_POLL_MS);
}
}

export { run };
17 changes: 16 additions & 1 deletion src/cli/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function formatWorkers(
: ` -- ${entry.name} ${entry.state}`,
)
.join("");
return `${label}: ${state} -- ${capacity}, ${leaseCount(worker)}${skew}${host}${capabilities}${load}${installs}`;
return `${label}: ${state} -- ${capacity}, ${leaseCount(worker)}${skew}${workerVersion(worker)}${host}${capabilities}${load}${installs}`;
})
.join("\n");
}
Expand All @@ -57,6 +57,21 @@ function workerState(worker: WorkerView): string {
].join(", ");
}

function workerVersion(worker: WorkerView): string {
const { latestVersion, update, version } = worker;
const available =
version !== undefined && latestVersion != null && isNewerVersion(latestVersion, version)
? ` (${latestVersion} available)`
: "";
const running = version === undefined ? "" : ` -- simlock ${version}${available}`;
return `${running}${update == null ? "" : ` -- updating to ${update.to}: ${updateStage(update)}`}`;
}

function updateStage(update: NonNullable<WorkerView["update"]>): string {
if (update.stage !== "waiting") return update.stage;
return `waiting for ${update.leases} ${update.leases === 1 ? "lease" : "leases"}`;
}

function leaseCount(worker: WorkerView): string {
if (worker.leases === undefined) return "leases unknown";
return `${String(worker.leases.length)} lease(s)`;
Expand Down
Loading
Loading