mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(managed-agent): reconcile a parked SSH Workspace registration (D7-2) A non-retryable SSH registration failure used to park recovery_blocked for good and keep the physical directory reserved. Now only a reserved registration parks, since only one that reserved can have reached its node through register-storage; an unreserved one fails outright. A parked registration joins the D7-2 reconciler. Its evidence is a read-only probe of its node one delay after the last attempt: the reserved directory with this registration's marker completes it, as the original attempt would have; no marker, or another directory at the root, fails it and releases the storage alias and the directory's owner; an unreadable node, a revoked actor or a foreign marker keeps it parked. Rows parked before this change reconcile the same way. The reconciler now takes stored kind names, lets a source settle with effects of its own, and lets a kind outside any Session list its own parked rows. * test(managed-agent): pin the parked SSH registration evidence and settlement Store: an unreserved registration fails outright, a reserved one parks for the delay, completes once when its marker landed, and fails with its directory released when it cannot land, without touching an owner another registration finished. Service: the node is only ever probed, and each verdict follows from what it holds. Reconciler: a kind outside any Session scans and settles through its own source. * docs(managed-agent): state the SSH registration reconciliation in contract 1.60.0 The registration operation no longer calls recovery_blocked terminal: an unreserved refusal fails, and a reserved registration stays blocked, with its directory, until a read-only probe of its node completes or releases it. The WebShell message says so instead of promising an administrator review in every case, and the D7-2 design doc gains its phase-2 section. * test(managed-agent): expect schema V8 in the SSH workspace MySQL IT D7-1 (#14404) moved the schema to V8 but left this IT asserting V7 after the full Flyway chain, so it failed on main under the mysql-integration profile, which no pull-request lane runs. * test(managed-agent): reserve before parking in the registration route fixtures The surface and contract fixtures failed an unreserved registration and expected recovery_blocked; that refusal is now final. They reserve first, so the route still reads a parked registration. The lock-order audit pins the record accessor the parked registration adds. * fix(web-shell): keep querying a blocked SSH registration The server now completes or releases a blocked registration once it reads the machine again, but the dialog stopped querying on recovery_blocked and dropped its continue action, so it stayed frozen on the blocked message until a reload. A blocked registration keeps "Continue confirming registration" beside "Start another registration", and a saved registration is queried each time the dialog opens; a later completed or failed result clears the blocked state. * fix(managed-agent): keep parked-operation evidence off the shared scheduler OperationReconciler read each parked row's evidence on the shared single-thread taskScheduler, and an SSH registration's evidence is a probe that can wait a minute on its node, so one slow node stalled every other coordinator's recovery tick. The scan now only lists due rows and hands each to the managed-agent executor; a row still being reconciled is not handed out again. The regression test runs the scan on a single-thread scheduler with a probe that blocks: the next task on that thread still runs, a second scan does not read the row again, and the settle follows the release. Run synchronously, it times out waiting for the other task.
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useI18n } from '../../i18n';
|
|
import { Button } from '../ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '../ui/dialog';
|
|
import { Input } from '../ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../ui/select';
|
|
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
|
import type {
|
|
ManagedAgentProvider,
|
|
ManagedAgentWorkspace,
|
|
ManagedRuntimeNode,
|
|
ManagedWorkspaceDirectory,
|
|
} from './managed-agent-provider';
|
|
import { managedRequestId } from './managed-session-storage';
|
|
|
|
interface RegistrationIntent {
|
|
nodeId: string;
|
|
path: string;
|
|
displayName: string;
|
|
idempotencyKey: string;
|
|
operationId?: string;
|
|
}
|
|
|
|
function readIntent(key: string): RegistrationIntent | undefined {
|
|
try {
|
|
const value: unknown = JSON.parse(sessionStorage.getItem(key) ?? 'null');
|
|
if (!value || typeof value !== 'object') return undefined;
|
|
const item = value as Record<string, unknown>;
|
|
if (
|
|
['nodeId', 'path', 'displayName', 'idempotencyKey'].some(
|
|
(name) => typeof item[name] !== 'string' || !item[name],
|
|
) ||
|
|
(item['operationId'] !== undefined &&
|
|
(typeof item['operationId'] !== 'string' || !item['operationId']))
|
|
)
|
|
return undefined;
|
|
return value as RegistrationIntent;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function persist(key: string, intent?: RegistrationIntent): boolean {
|
|
try {
|
|
if (intent) sessionStorage.setItem(key, JSON.stringify(intent));
|
|
else sessionStorage.removeItem(key);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pause(signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const done = () => {
|
|
clearTimeout(timer);
|
|
signal.removeEventListener('abort', done);
|
|
resolve();
|
|
};
|
|
const timer = setTimeout(done, 1_000);
|
|
signal.addEventListener('abort', done, { once: true });
|
|
if (signal.aborted) done();
|
|
});
|
|
}
|
|
|
|
export function RemoteWorkspaceRegistration({
|
|
provider,
|
|
clientId,
|
|
open,
|
|
onOpenChange,
|
|
onAvailable,
|
|
onRegistered,
|
|
}: {
|
|
provider: ManagedAgentProvider;
|
|
clientId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onAvailable: (available: boolean) => void;
|
|
onRegistered: (workspace: ManagedAgentWorkspace) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const key = `qwen-managed-ssh-workspace-registration:${provider.storageKey}`;
|
|
const [pending, setPending] = useState(() => readIntent(key));
|
|
const pendingRef = useRef(pending);
|
|
const [nodes, setNodes] = useState<ManagedRuntimeNode[]>([]);
|
|
const [nodeId, setNodeId] = useState(pending?.nodeId ?? '');
|
|
const [path, setPath] = useState(pending?.path ?? '');
|
|
const [displayName, setDisplayName] = useState(pending?.displayName ?? '');
|
|
const [checked, setChecked] = useState<ManagedWorkspaceDirectory>();
|
|
const [busy, setBusy] = useState(false);
|
|
const [blocked, setBlocked] = useState(false);
|
|
const [error, setError] = useState<string>();
|
|
const running = useRef(false);
|
|
const lifetime = useRef<AbortController | undefined>(undefined);
|
|
const current = useRef({ provider, onOpenChange, onAvailable, onRegistered });
|
|
current.current = { provider, onOpenChange, onAvailable, onRegistered };
|
|
const registration = provider.workspaceBinding?.registration;
|
|
|
|
function updatePending(intent?: RegistrationIntent) {
|
|
pendingRef.current = intent;
|
|
setPending(intent);
|
|
if (!persist(key, intent))
|
|
setError(t('managed.workspaceStorageUnavailable'));
|
|
}
|
|
|
|
async function confirm(intent: RegistrationIntent) {
|
|
const owner = lifetime.current;
|
|
const binding = current.current.provider.workspaceBinding;
|
|
const api = binding?.registration;
|
|
if (!owner || owner.signal.aborted || running.current || !api) return;
|
|
running.current = true;
|
|
setBusy(true);
|
|
setError(undefined);
|
|
const abort = new AbortController();
|
|
const stop = () => abort.abort();
|
|
owner.signal.addEventListener('abort', stop, { once: true });
|
|
const timer = setTimeout(stop, 30_000);
|
|
try {
|
|
const options = { clientId, signal: abort.signal };
|
|
let operation = intent.operationId
|
|
? await api.query(intent.operationId, options)
|
|
: await api.create(
|
|
{
|
|
nodeId: intent.nodeId,
|
|
path: intent.path,
|
|
displayName: intent.displayName,
|
|
},
|
|
{ ...options, idempotencyKey: intent.idempotencyKey },
|
|
);
|
|
while (true) {
|
|
if (owner.signal.aborted) return;
|
|
abort.signal.throwIfAborted();
|
|
if (
|
|
operation.type !== 'workspace_create' ||
|
|
!operation.operationId ||
|
|
(intent.operationId && operation.operationId !== intent.operationId)
|
|
)
|
|
throw new Error('Workspace operation identity mismatch');
|
|
if (!intent.operationId) {
|
|
intent = { ...intent, operationId: operation.operationId };
|
|
updatePending(intent);
|
|
}
|
|
if (operation.status === 'completed') {
|
|
if (!operation.workspaceId)
|
|
throw new Error('Workspace operation is missing its Workspace');
|
|
const workspace = await binding!.get(operation.workspaceId, options);
|
|
if (owner.signal.aborted) return;
|
|
abort.signal.throwIfAborted();
|
|
if (
|
|
workspace.workspaceId !== operation.workspaceId ||
|
|
!workspace.canCreateSession ||
|
|
workspace.state !== 'active'
|
|
) {
|
|
setError(t('managed.remoteWorkspacePermission'));
|
|
return;
|
|
}
|
|
updatePending();
|
|
setBlocked(false);
|
|
current.current.onRegistered(workspace);
|
|
current.current.onOpenChange(false);
|
|
return;
|
|
}
|
|
if (operation.status === 'failed' || operation.status === 'cancelled') {
|
|
updatePending();
|
|
setBlocked(false);
|
|
setChecked(undefined);
|
|
setError(t('managed.remoteWorkspaceFailed'));
|
|
return;
|
|
}
|
|
if (operation.status === 'recovery_blocked') {
|
|
setChecked(undefined);
|
|
setBlocked(true);
|
|
setError(t('managed.remoteWorkspaceBlocked'));
|
|
return;
|
|
}
|
|
if (operation.status !== 'pending' && operation.status !== 'running')
|
|
throw new Error(t('managed.remoteWorkspaceUnconfirmed'));
|
|
await pause(abort.signal);
|
|
abort.signal.throwIfAborted();
|
|
operation = await api.query(intent.operationId!, options);
|
|
}
|
|
} catch (failure) {
|
|
if (!owner.signal.aborted) {
|
|
updatePending(intent);
|
|
setError(
|
|
failure instanceof JavaManagedAgentHttpError &&
|
|
(failure.status === 401 || failure.status === 403)
|
|
? t('managed.remoteWorkspacePermission')
|
|
: t('managed.remoteWorkspaceUnconfirmed'),
|
|
);
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
owner.signal.removeEventListener('abort', stop);
|
|
abort.abort();
|
|
if (!owner.signal.aborted) {
|
|
running.current = false;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const abort = new AbortController();
|
|
lifetime.current = abort;
|
|
running.current = false;
|
|
if (pendingRef.current) {
|
|
current.current.onOpenChange(true);
|
|
if (pendingRef.current.operationId) void confirm(pendingRef.current);
|
|
}
|
|
return () => abort.abort();
|
|
// The parent keys this component by the provider's tenant/actor storage scope.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key]);
|
|
|
|
// The server settles a blocked registration on its own once it reads the
|
|
// machine again, so a saved registration is queried each time the dialog
|
|
// opens; a confirmation already running absorbs the mount's own opening.
|
|
useEffect(() => {
|
|
if (open && pendingRef.current?.operationId)
|
|
void confirm(pendingRef.current);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
const abort = new AbortController();
|
|
setNodes([]);
|
|
if (registration) {
|
|
void registration
|
|
.listNodes({ clientId, signal: abort.signal })
|
|
.then((page) => {
|
|
if (abort.signal.aborted) return;
|
|
const available = page.supported ? page.data : [];
|
|
setNodes(available);
|
|
})
|
|
.catch(() => {
|
|
if (!abort.signal.aborted) setNodes([]);
|
|
});
|
|
}
|
|
return () => abort.abort();
|
|
}, [registration, clientId]);
|
|
|
|
useEffect(() => {
|
|
current.current.onAvailable(nodes.length > 0 || Boolean(pending));
|
|
}, [nodes, pending]);
|
|
|
|
async function checkDirectory() {
|
|
const abort = lifetime.current;
|
|
const api = current.current.provider.workspaceBinding?.registration;
|
|
if (!abort || !api || running.current || pendingRef.current) return;
|
|
running.current = true;
|
|
setBusy(true);
|
|
setChecked(undefined);
|
|
setError(undefined);
|
|
try {
|
|
const result = await api.checkDirectory(
|
|
{ nodeId, path },
|
|
{ clientId, signal: abort.signal },
|
|
);
|
|
if (abort.signal.aborted) return;
|
|
if (result.nodeId !== nodeId || !result.canonicalPath)
|
|
throw new Error('Workspace directory identity mismatch');
|
|
setChecked(result);
|
|
} catch (failure) {
|
|
if (!abort.signal.aborted)
|
|
setError(
|
|
failure instanceof JavaManagedAgentHttpError &&
|
|
(failure.status === 401 || failure.status === 403)
|
|
? t('managed.remoteWorkspacePermission')
|
|
: t('managed.remoteWorkspaceInvalidDirectory'),
|
|
);
|
|
} finally {
|
|
if (!abort.signal.aborted) {
|
|
running.current = false;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
function create() {
|
|
if (
|
|
running.current ||
|
|
pendingRef.current ||
|
|
!checked ||
|
|
checked.nodeId !== nodeId ||
|
|
!displayName.trim() ||
|
|
!nodes.some((node) => node.nodeId === nodeId)
|
|
)
|
|
return;
|
|
const intent = {
|
|
nodeId,
|
|
path: checked.canonicalPath,
|
|
displayName: displayName.trim(),
|
|
idempotencyKey: managedRequestId(),
|
|
};
|
|
if (!persist(key, intent)) {
|
|
setError(t('managed.workspaceStorageUnavailable'));
|
|
return;
|
|
}
|
|
updatePending(intent);
|
|
void confirm(intent);
|
|
}
|
|
|
|
function discardBlocked() {
|
|
if (!blocked || running.current) return;
|
|
if (!persist(key)) {
|
|
setError(t('managed.workspaceStorageUnavailable'));
|
|
return;
|
|
}
|
|
pendingRef.current = undefined;
|
|
setPending(undefined);
|
|
setBlocked(false);
|
|
setChecked(undefined);
|
|
setError(undefined);
|
|
}
|
|
|
|
const frozen = busy || Boolean(pending);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(value) => {
|
|
if (!busy) onOpenChange(value);
|
|
}}
|
|
>
|
|
<DialogContent showCloseButton={!busy}>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('managed.remoteWorkspaceAdd')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('managed.remoteWorkspaceDescription')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<label htmlFor="managed-runtime-node">
|
|
{t('managed.remoteWorkspaceMachine')}
|
|
</label>
|
|
<Select
|
|
value={nodeId}
|
|
disabled={frozen}
|
|
onValueChange={(value) => {
|
|
setNodeId(value);
|
|
setChecked(undefined);
|
|
setError(undefined);
|
|
}}
|
|
>
|
|
<SelectTrigger id="managed-runtime-node" className="w-full">
|
|
<SelectValue
|
|
placeholder={t('managed.remoteWorkspaceChooseMachine')}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{nodes.map((node) => (
|
|
<SelectItem key={node.nodeId} value={node.nodeId}>
|
|
{node.displayName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<label htmlFor="managed-runtime-directory">
|
|
{t('managed.remoteWorkspaceDirectory')}
|
|
</label>
|
|
<Input
|
|
id="managed-runtime-directory"
|
|
value={path}
|
|
disabled={frozen}
|
|
onChange={(event) => {
|
|
setPath(event.target.value);
|
|
setChecked(undefined);
|
|
setError(undefined);
|
|
}}
|
|
/>
|
|
<label htmlFor="managed-runtime-name">
|
|
{t('managed.remoteWorkspaceName')}
|
|
</label>
|
|
<Input
|
|
id="managed-runtime-name"
|
|
value={displayName}
|
|
disabled={frozen}
|
|
onChange={(event) => setDisplayName(event.target.value)}
|
|
/>
|
|
{checked && <p role="status">{checked.canonicalPath}</p>}
|
|
{busy && <p role="status">{t('managed.remoteWorkspaceWorking')}</p>}
|
|
{pending && !blocked && (
|
|
<p>{t('managed.remoteWorkspaceUnconfirmed')}</p>
|
|
)}
|
|
{error && <p role="alert">{error}</p>}
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
{t('managed.remoteWorkspaceClose')}
|
|
</Button>
|
|
{blocked && pending ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={discardBlocked}
|
|
>
|
|
{t('managed.remoteWorkspaceStartAnother')}
|
|
</Button>
|
|
<Button disabled={busy} onClick={() => void confirm(pending)}>
|
|
{t('managed.remoteWorkspaceConfirm')}
|
|
</Button>
|
|
</>
|
|
) : pending ? (
|
|
<Button disabled={busy} onClick={() => void confirm(pending)}>
|
|
{t('managed.remoteWorkspaceConfirm')}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
disabled={
|
|
busy ||
|
|
!path.trim() ||
|
|
!nodes.some((node) => node.nodeId === nodeId)
|
|
}
|
|
onClick={() => void checkDirectory()}
|
|
>
|
|
{t('managed.remoteWorkspaceCheck')}
|
|
</Button>
|
|
<Button
|
|
disabled={busy || !checked || !displayName.trim()}
|
|
onClick={create}
|
|
>
|
|
{t('managed.remoteWorkspaceRegister')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|