fix: separate worker state from runtime lifecycle

This commit is contained in:
2026-09-06 10:05:54 +09:00
parent 282a8d31b5
commit 2d1956b653
21 changed files with 602 additions and 188 deletions
+11 -2
View File
@@ -19,7 +19,7 @@ command_id: number, expected_execution_generation: number, expected_worker_state
export type WorkerCommandKind = "resume" | "cancel" | "pause" | "compact" | "shutdown";
export type WorkerCommandDisposition = "accepted" | "stale_execution_generation" | "stale_worker_state_revision" | "stale_command_id" | "invalid_state";
export type WorkerCommandDisposition = "accepted" | "stale_execution_generation" | "stale_worker_state_revision" | "stale_command_id" | "conflict" | "invalid_state";
export type WorkerCommandAcknowledgement = { command_id: number, command: WorkerCommandKind, disposition: WorkerCommandDisposition,
/**
@@ -233,7 +233,16 @@ resource_key?: string | null,
/**
* Producer-owned monotonic revision for this Worker subject.
*/
subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
subject_revision: number,
/**
* Latest revisioned foreground state observed from the Worker. This remains
* absent until an authoritative Worker snapshot/event has been applied.
*/
worker_state?: WorkerStateSnapshot | null,
/**
* Runtime catalog lifecycle compatibility projection; not foreground-state authority.
*/
state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
/**
* Workspace-facing Repository key. Runtime producers leave this unset and
* Workspace Server projections replace `repository_id` with this field.
@@ -19,6 +19,7 @@ import type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment,
WorkerStateSnapshot,
} from "$lib/generated/protocol";
import type {
GitCommitSummary as SharedGitCommitSummary,
@@ -99,6 +100,7 @@ export type Worker = {
tags: string[];
workspace: { visibility: string; identity: string };
state: string;
worker_state?: WorkerStateSnapshot | null;
pinned?: boolean;
retention_state?: string;
last_seen_at?: string | null;
@@ -0,0 +1,12 @@
import type { WorkerStateSnapshot } from "$lib/generated/protocol";
export function liveWorkerState(worker: {
state: string;
worker_state?: WorkerStateSnapshot | null;
}): string {
const state = worker.worker_state?.state;
if (!state) return worker.state === "stopped" ? "stopped" : "unknown";
if (state.kind === "idle") return "idle";
if (state.state.kind === "maintenance") return "running";
return state.state.state === "paused" ? "paused" : "running";
}
@@ -5,6 +5,7 @@ function assertEquals(actual: unknown, expected: unknown): void {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
import { liveWorkerState } from './worker-state';
import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
@@ -33,6 +34,22 @@ function worker(
};
}
Deno.test('Worker list state uses the authoritative live snapshot separately from lifecycle', () => {
const active = worker('runtime-a', 'worker-1', 1);
active.worker_state = {
execution_generation: 4,
revision: 2,
last_command_id: 1,
state: { kind: 'busy', state: { kind: 'run', state: 'paused' } },
};
assertEquals(liveWorkerState(active), 'paused');
const unavailable = worker('runtime-a', 'worker-2', 1);
assertEquals(liveWorkerState(unavailable), 'unknown');
unavailable.state = 'stopped';
assertEquals(liveWorkerState(unavailable), 'stopped');
});
Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => {
const projection = createWorkspaceWorkersProjection();
const frame: SubscriptionFrame = {
@@ -5,6 +5,7 @@ import {
applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection,
} from './worker-subscription-model';
import { liveWorkerState } from './worker-state';
import { compareWorkersForSidebar } from './workers';
import type { Worker } from './types';
@@ -86,7 +87,8 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
profile: worker.profile ?? null,
tags: [],
workspace: { visibility: 'workspace', identity: 'runtime_subscription_worker' },
state: worker.state,
state: liveWorkerState(worker),
worker_state: worker.worker_state,
pinned: false,
retention_state: 'transient',
implementation: {
@@ -52,11 +52,7 @@
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
import { workspaceApiPath } from "$lib/workspace/api/http";
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
import type {
Diagnostic,
Worker,
PodProtocolEvent,
} from "$lib/workspace/sidebar/types";
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
type Props = {
data: {
@@ -207,7 +203,6 @@
);
let pendingObservationEvents: ConsoleEventInput[] = [];
let protocolEventSequence = 0;
let pendingObservedStates: Array<string | null> = [];
let pendingStreamDiagnostics: Diagnostic[] = [];
let observationFlushHandle: number | null = null;
let nextReloadToken = 0;
@@ -249,7 +244,9 @@
const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
);
const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading");
const workerState = $derived(
liveWorkerState ?? (worker?.state === "stopped" ? "stopped" : "loading"),
);
const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused");
const composerEditable = $derived(protocolState === "open" && !sending);
@@ -343,7 +340,6 @@
observationFlushHandle = null;
}
pendingObservationEvents = [];
pendingObservedStates = [];
pendingStreamDiagnostics = [];
}
@@ -359,18 +355,15 @@
function flushObservationBatch() {
observationFlushHandle = null;
const eventBatch = pendingObservationEvents;
const stateBatch = pendingObservedStates;
const diagnosticBatch = pendingStreamDiagnostics;
pendingObservationEvents = [];
pendingObservedStates = [];
pendingStreamDiagnostics = [];
if (eventBatch.length > 0) {
const latestState = stateBatch.findLast((state) => state !== null);
if (latestState) {
liveWorkerState = latestState;
}
consoleProjection = consoleProjector.append(eventBatch);
liveWorkerState = consoleProjection.status === "shutdown"
? "shutdown"
: workerStateFromSnapshot(consoleProjection.workerState);
advanceEventObservedAtVersion();
}
@@ -407,7 +400,6 @@
event: payload,
observedAtMs,
});
pendingObservedStates.push(workerStateFromProtocolEvent(payload));
scheduleObservationFlush();
}
@@ -925,36 +917,16 @@
handleComposerSubmit();
}
function workerStateFromProtocolEvent(
event: PodProtocolEvent,
function workerStateFromSnapshot(
snapshot: ConsoleProjection["workerState"],
): string | null {
switch (event.event) {
case "snapshot":
return event.data.state.state.kind === "idle"
? "idle"
: event.data.state.state.state.kind === "run" &&
event.data.state.state.state.state === "paused"
? "paused"
: "running";
case "worker_state":
return event.data.snapshot.state.kind === "idle"
? "idle"
: event.data.snapshot.state.state.kind === "run" &&
event.data.snapshot.state.state.state === "paused"
? "paused"
: "running";
case "command_acknowledged":
return event.data.acknowledgement.state.state.kind === "idle"
? "idle"
: event.data.acknowledgement.state.state.state.kind === "run" &&
event.data.acknowledgement.state.state.state.state === "paused"
? "paused"
: "running";
case "shutdown":
return "shutdown";
default:
return null;
}
if (!snapshot) return null;
return snapshot.state.kind === "idle"
? "idle"
: snapshot.state.state.kind === "run" &&
snapshot.state.state.state === "paused"
? "paused"
: "running";
}
function connectProtocolTransport(
@@ -4,6 +4,7 @@
import { workerHref } from '$lib/workspace/resource-links';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
@@ -136,7 +137,7 @@
}
function workerStatus(worker: Worker): string {
return worker.state;
return liveWorkerState(worker);
}
function workerProfile(worker: Worker): string {
@@ -1,5 +1,6 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
@@ -24,7 +25,7 @@
>Open console</a>
</header>
<dl class="resource-meta">
<dt>Status</dt><dd>{data.worker.state}</dd>
<dt>Status</dt><dd>{liveWorkerState(data.worker)}</dd>
<dt>Profile</dt><dd>{data.worker.profile}</dd>
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
</dl>