feat: persist worker submit activation queue

This commit is contained in:
2026-09-05 22:06:59 +09:00
parent aa96bbedbc
commit bb56283063
41 changed files with 2336 additions and 811 deletions
+9 -3
View File
@@ -103,7 +103,13 @@ entry_id: string,
*/
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type SessionSnapshot = { entries: Array<SessionSnapshotEntry>, };
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, submissions: Array<PendingSubmissionSummary>, };
export type SubmissionDisposition = "started" | "queued";
export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array<SessionSnapshotEntry>, };
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
@@ -225,9 +231,9 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, } } | { "method": "clear_pending_submissions" } | { "method": "continue_pending" } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/**
* The attempt that just failed. 1 origin.
*/
@@ -2150,6 +2150,7 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
const event = snapshotEvent("/repo");
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
event.data.session = {
pending_submissions: { revision: 0, notification_count: 0, submissions: [] },
entries: [{
entry_id: "task-reminder-1",
timestamp: 1,
@@ -1059,3 +1059,25 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
"Worker view selection should expose only direct SubWorker session identities with main fallback",
);
});
Deno.test("Web Console uses Notify while running and exposes durable pending controls", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
for (const token of [
'method: "submit"',
'method: "notify"',
"notification_request_id: crypto.randomUUID()",
"submission_request_id: crypto.randomUUID()",
'payload.event === "pending_submissions_changed"',
'method: "cancel_pending_submission"',
'method: "clear_pending_submissions"',
'method: "continue_pending"',
]) {
assert(consolePage.includes(token), `missing durable pending control token: ${token}`);
}
});
@@ -31,7 +31,13 @@
type ConsoleViewMode,
type ConsoleViewScroll,
} from "$lib/workspace/console/model";
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
import type {
Event as ProtocolEvent,
Method as ProtocolMethod,
PendingSubmissionsSnapshot,
RewindTarget,
Segment,
} from "$lib/generated/protocol";
import {
MAX_FILES_PER_SUBMISSION,
uploadAttachment,
@@ -152,6 +158,12 @@
"connecting",
);
let protocolSubscription: WorkspaceMultiplexerSubscription | null = null;
let pendingSubmissions = $state<PendingSubmissionsSnapshot>({
revision: 0,
notification_count: 0,
submissions: [],
});
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
let pendingCompletionRequest: {
resolve: (entries: ComposerCompletionEntry[]) => void;
reject: (error: Error) => void;
@@ -334,6 +346,13 @@
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
handleProtocolCommandEvent(payload);
if (payload.event === "snapshot") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "segment_rotated") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "pending_submissions_changed") {
pendingSubmissions = payload.data.pending;
}
if (payload.event === "error") {
queueObservationDiagnostic({
code: payload.data.code,
@@ -555,9 +574,20 @@
): ProtocolMethod {
switch (request.kind) {
case "user":
if (workerRunning) {
return {
method: "notify",
params: {
notification_request_id: crypto.randomUUID(),
message: request.content,
auto_run: true,
},
};
}
return {
method: "run",
method: "submit",
params: {
submission_request_id: crypto.randomUUID(),
input: request.segments ?? [
{ kind: "text", content: request.content },
],
@@ -566,7 +596,11 @@
case "notify":
return {
method: "notify",
params: { message: request.content, auto_run: true },
params: {
notification_request_id: crypto.randomUUID(),
message: request.content,
auto_run: true,
},
};
case "compact":
return { method: "compact" };
@@ -779,7 +813,7 @@
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
attachments = [];
if (method.method === "run" || method.method === "notify") {
if (method.method === "submit" || method.method === "notify") {
liveWorkerState = "running";
}
composerNotice = "Sent through Worker protocol.";
@@ -1722,6 +1756,50 @@
</aside>
{/if}
{#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0}
<details class="pending-submissions">
<summary>
Pending activations ({pendingSubmissionItems.length} submissions · {pendingSubmissions.notification_count} notifications)
</summary>
<ol>
{#each pendingSubmissionItems as submission (submission.submission_id)}
<li>
<code>{submission.submission_id}</code>
<span>{submission.segment_count} segments · {submission.byte_len} bytes</span>
<button
type="button"
onclick={() =>
sendControl(
{
method: "cancel_pending_submission",
params: { submission_id: submission.submission_id },
},
"Pending submission cancellation",
)}
>Cancel</button>
</li>
{/each}
</ol>
<button
type="button"
disabled={workerRunning}
onclick={() =>
sendControl(
{ method: "continue_pending" },
"Pending activation continue",
)}
>Continue next</button>
<button
type="button"
onclick={() =>
sendControl(
{ method: "clear_pending_submissions" },
"Pending submissions clear",
)}
>Clear all</button>
</details>
{/if}
{#if workerRunning}
<WorkerRunStatus
startedAtMs={consoleProjection.runActivity.startedAtMs}
@@ -2035,6 +2113,31 @@
display: none;
}
.pending-submissions {
margin: 0 var(--space-3);
color: var(--muted);
font-size: 0.75rem;
}
.pending-submissions ol {
display: grid;
gap: var(--space-1);
margin: var(--space-2) 0;
padding-left: var(--space-5);
}
.pending-submissions li {
display: flex;
gap: var(--space-2);
align-items: center;
}
.pending-submissions code {
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
}
.console-log {
display: grid;
align-content: start;