Add agent restart lifecycle command
Extends the existing CheckIn RPC with a one-shot AgentCommand (restart only -- stop/uninstall need real per-platform OS service-manager integration and stay deliberately out of scope), delivered at-most-once: cleared the instant it's handed to the agent in a response, since a restarting agent's process is gone before it could ever confirm receipt. On restart, the agent flushes whatever's buffered, aborts its source task, and exits cleanly, relying entirely on the host's own service manager to bring it back up. Issuing a command is gated at RoleAdmin (stricter than config editing's RoleEditor) and logged into the same audit_log table Phase 7's AI interactions use, via a new agent_command event type. A real bug was found and fixed during live verification: the first implementation tried to atomically read-and-clear pending_command in a single INSERT...ON CONFLICT statement using a sibling CTE referenced only from RETURNING, on the assumption that Postgres evaluates every part of a WITH query against one pre-statement snapshot. That's wrong specifically for FOR UPDATE, which always reads the latest row version including one written earlier in the same statement -- confirmed empirically (a restart command was always coming back empty even when genuinely pending, so the agent never received it). Fixed by splitting into two real, ordered statements inside one explicit transaction. See /docs/agent-management-design.md's "Lifecycle commands" section.
This commit is contained in:
@@ -505,6 +505,15 @@ export type Agent = {
|
||||
applied_override_version: string;
|
||||
pending: boolean;
|
||||
updated_by?: string;
|
||||
// pending_command/command_issued_at/by (real, restart only for now
|
||||
// -- see /docs/agent-management-design.md's punch list) have no
|
||||
// "delivered" signal to show the way config's `pending` does:
|
||||
// ingest clears pending_command the instant it hands the command to
|
||||
// the agent, not once the agent confirms it ran, so
|
||||
// command_issued_at is shown as a last-issued record instead.
|
||||
pending_command?: string;
|
||||
command_issued_at?: string;
|
||||
command_issued_by?: string;
|
||||
};
|
||||
|
||||
export function listAgents(): Promise<Agent[]> {
|
||||
@@ -525,3 +534,14 @@ export function setAgentConfig(host: string, override: ConfigOverride): Promise<
|
||||
export function clearAgentConfig(host: string): Promise<void> {
|
||||
return request(`/agents/${encodeURIComponent(host)}/config`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// "restart" is the only supported value today -- server-validated
|
||||
// (400 on anything else), RoleAdmin-gated (403 for a Viewer/Editor
|
||||
// session), and audit-logged. See /docs/agent-management-design.md's
|
||||
// punch list for why stop/uninstall aren't here yet.
|
||||
export function issueAgentCommand(host: string, command: 'restart'): Promise<Agent> {
|
||||
return request(`/agents/${encodeURIComponent(host)}/command`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { getAgent, setAgentConfig, clearAgentConfig, type Agent } from '$lib/api';
|
||||
import { getAgent, setAgentConfig, clearAgentConfig, issueAgentCommand, type Agent } from '$lib/api';
|
||||
import { Badge, Button, Input, Skeleton } from '$lib/components/ui';
|
||||
|
||||
const host = page.params.host!;
|
||||
@@ -82,6 +82,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Two-step arm/confirm rather than a single click -- restart briefly
|
||||
// interrupts log collection for this one host, a higher blast
|
||||
// radius than the config edits above (which never take effect until
|
||||
// the agent's own next check-in, and never disrupt anything by
|
||||
// themselves). Resets if the user navigates the form instead of
|
||||
// confirming.
|
||||
let restartArmed = $state(false);
|
||||
let restarting = $state(false);
|
||||
let restartError = $state('');
|
||||
|
||||
async function restart() {
|
||||
if (!restartArmed) {
|
||||
restartArmed = true;
|
||||
return;
|
||||
}
|
||||
restarting = true;
|
||||
restartError = '';
|
||||
try {
|
||||
agent = await issueAgentCommand(host, 'restart');
|
||||
} catch (e) {
|
||||
restartError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
restarting = false;
|
||||
restartArmed = false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
|
||||
@@ -162,6 +189,32 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lifecycle">
|
||||
<h2>Lifecycle</h2>
|
||||
<p class="hint">
|
||||
Restart tells the agent to shut down gracefully (flushing anything buffered first) and exit -- it relies on the
|
||||
host's own service manager (systemd, Windows SCM) to bring it back up, same as this agent already expects from
|
||||
a normal crash or `systemctl restart`. Delivered on the agent's next check-in; there's no confirmation once
|
||||
it's been handed out, since a restarting agent can't report back before its process exits.
|
||||
</p>
|
||||
{#if agent.command_issued_at}
|
||||
<p class="hint">
|
||||
Last restart issued {relativeTime(agent.command_issued_at)}{agent.command_issued_by
|
||||
? ` by ${agent.command_issued_by}`
|
||||
: ''}.
|
||||
</p>
|
||||
{/if}
|
||||
{#if restartError}<p class="error">Error: {restartError}</p>{/if}
|
||||
<div class="actions">
|
||||
{#if restartArmed}
|
||||
<Button variant="danger" onclick={restart} disabled={restarting}>Confirm restart</Button>
|
||||
<Button variant="secondary" onclick={() => (restartArmed = false)} disabled={restarting}>Cancel</Button>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={restart}>Restart agent</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -195,6 +248,9 @@
|
||||
.reported {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.lifecycle {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
||||
Reference in New Issue
Block a user