feat(mcp,orchestrator,frontend): tool idea_restart_self, redemarrage autonome d'un agent — #288 (tool core self-only via requester MCP handshake, retour immediat {restartId, status: scheduled} puis orchestration detachee: snapshot reprise, stop PTY/structured, liberation Busy/mailbox, relance LaunchAgent meme conversation_id fresh_start=false, injection interne invisible; events domain/wire agentRestartRequested/PhaseChanged/Completed/Failed phases stopping/releasingInput/launching/resuming; CLI custom: separateurs exacts redemarrage de l'agent + etat termine/echoue, deduplication (restartId, event), verrouillage composer; TUI native: overlay AgentRestartOverlay sans injection xterm, progress/annonces projetes jusqu'a idle; QA verte: mcp_server restart_self 1/1, restart-self 3/3, backend self_restart_contract + events wire 2/2, frontend 3 suites 92/92, npm build, cargo check --workspace, fmt --check, diff --check; reserves hors perimetre: web-server 103/110 fixture PTY preexistante, 34 echecs npm test globaux listProjectSettings/web hors #288; scripts/ hors perimetre)
This commit is contained in:
@ -11,6 +11,10 @@
|
||||
* 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated
|
||||
* from the reconciled read-model `ProjectWorkState.agents[].busy` at mount /
|
||||
* reboot (via {@link useHydrateAgentBusy}).
|
||||
* 3. **Self-restart presentation** — the live restart phase plus a small,
|
||||
* deduplicated journal used by the native-TUI overlay and custom-CLI
|
||||
* separators. It is deliberately presentation-only and never persisted to
|
||||
* the human conversation transcript.
|
||||
*
|
||||
* Why busy — not "there are announcements" — drives F3: a turn can end **without**
|
||||
* any completion event (interruption / error / crash / rate-limit). The mediator's
|
||||
@ -33,11 +37,13 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { AgentRestartPhase, DomainEvent } from "@/domain";
|
||||
import {
|
||||
announcementsForRequester,
|
||||
announcementsForTarget,
|
||||
@ -51,11 +57,33 @@ import {
|
||||
/** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */
|
||||
type BusyMap = Record<string, boolean>;
|
||||
|
||||
export type AgentRestartJournalEvent = Extract<
|
||||
DomainEvent,
|
||||
{
|
||||
type:
|
||||
| "agentRestartRequested"
|
||||
| "agentRestartCompleted"
|
||||
| "agentRestartFailed";
|
||||
}
|
||||
>;
|
||||
|
||||
export interface AgentRestartPresentation {
|
||||
restartId: string;
|
||||
phase: AgentRestartPhase | "requested";
|
||||
outcome: "running" | "completed" | "failed";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type RestartMap = Record<string, AgentRestartPresentation>;
|
||||
type RestartJournal = Record<string, AgentRestartJournalEvent[]>;
|
||||
|
||||
interface AnnouncementsStore {
|
||||
/** Bounded announcement content, indexed by `(target, ticketId)`. */
|
||||
index: AnnouncementIndex;
|
||||
/** Per-agent busy state — the overlay's lifecycle authority. */
|
||||
busy: BusyMap;
|
||||
restarts: RestartMap;
|
||||
restartJournal: RestartJournal;
|
||||
/**
|
||||
* Seeds an agent's busy state from the read-model, but only when no live signal
|
||||
* is yet known for it: live `agentBusyChanged` events always win over a
|
||||
@ -67,11 +95,15 @@ interface AnnouncementsStore {
|
||||
const noopSeed = () => {};
|
||||
const EMPTY_INDEX = emptyAnnouncementIndex();
|
||||
const EMPTY_BUSY: BusyMap = {};
|
||||
const EMPTY_RESTARTS: RestartMap = {};
|
||||
const EMPTY_RESTART_JOURNAL: RestartJournal = {};
|
||||
|
||||
/** Store shape read outside a provider — a silent, inert default (never throws). */
|
||||
const DEFAULT_STORE: AnnouncementsStore = {
|
||||
index: EMPTY_INDEX,
|
||||
busy: EMPTY_BUSY,
|
||||
restarts: EMPTY_RESTARTS,
|
||||
restartJournal: EMPTY_RESTART_JOURNAL,
|
||||
seedBusy: noopSeed,
|
||||
};
|
||||
|
||||
@ -87,16 +119,24 @@ export function AnnouncementsProvider({
|
||||
const { system } = useGateways();
|
||||
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
||||
const [busy, setBusy] = useState<BusyMap>({});
|
||||
const busyRef = useRef<BusyMap>({});
|
||||
const [restarts, setRestarts] = useState<RestartMap>({});
|
||||
const [restartJournal, setRestartJournal] = useState<RestartJournal>({});
|
||||
|
||||
// Hydration seed: apply only when the agent is unknown, so a live event that
|
||||
// already landed (or lands before the async read-model resolves) is not clobbered.
|
||||
const seedBusy = useCallback((agentId: string, value: boolean) => {
|
||||
setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value }));
|
||||
if (agentId in busyRef.current) return;
|
||||
busyRef.current = { ...busyRef.current, [agentId]: value };
|
||||
setBusy((prev) => ({ ...prev, [agentId]: value }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(emptyAnnouncementIndex());
|
||||
setBusy({});
|
||||
busyRef.current = {};
|
||||
setRestarts({});
|
||||
setRestartJournal({});
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -124,8 +164,71 @@ export function AnnouncementsProvider({
|
||||
// it (idle) and clears the target's content so a next turn starts fresh —
|
||||
// even when no completion event was ever emitted (the sticking case).
|
||||
const { agentId, busy: isBusy } = event;
|
||||
busyRef.current = { ...busyRef.current, [agentId]: isBusy };
|
||||
setBusy((prev) => ({ ...prev, [agentId]: isBusy }));
|
||||
if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId));
|
||||
if (!isBusy) {
|
||||
setRestarts((prev) => {
|
||||
const restart = prev[agentId];
|
||||
if (!restart || restart.outcome !== "completed") return prev;
|
||||
const next = { ...prev };
|
||||
delete next[agentId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else if (event.type === "agentRestartRequested") {
|
||||
setRestarts((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
restartId: event.restartId,
|
||||
phase: "requested",
|
||||
outcome: "running",
|
||||
},
|
||||
}));
|
||||
setRestartJournal((prev) => appendRestartJournal(prev, event));
|
||||
} else if (event.type === "agentRestartPhaseChanged") {
|
||||
setRestarts((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current || current.restartId !== event.restartId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[event.agentId]: { ...current, phase: event.phase },
|
||||
};
|
||||
});
|
||||
} else if (event.type === "agentRestartCompleted") {
|
||||
setRestarts((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current || current.restartId !== event.restartId) return prev;
|
||||
if (busyRef.current[event.agentId] === false) {
|
||||
const next = { ...prev };
|
||||
delete next[event.agentId];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...current,
|
||||
phase: "resuming",
|
||||
outcome: "completed",
|
||||
},
|
||||
};
|
||||
});
|
||||
setRestartJournal((prev) => appendRestartJournal(prev, event));
|
||||
} else if (event.type === "agentRestartFailed") {
|
||||
setRestarts((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current || current.restartId !== event.restartId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...current,
|
||||
phase: event.phase,
|
||||
outcome: "failed",
|
||||
message: event.message,
|
||||
},
|
||||
};
|
||||
});
|
||||
setRestartJournal((prev) => appendRestartJournal(prev, event));
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
@ -143,8 +246,8 @@ export function AnnouncementsProvider({
|
||||
}, [system, projectId]);
|
||||
|
||||
const store = useMemo<AnnouncementsStore>(
|
||||
() => ({ index, busy, seedBusy }),
|
||||
[index, busy, seedBusy],
|
||||
() => ({ index, busy, restarts, restartJournal, seedBusy }),
|
||||
[index, busy, restarts, restartJournal, seedBusy],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -154,6 +257,19 @@ export function AnnouncementsProvider({
|
||||
);
|
||||
}
|
||||
|
||||
function appendRestartJournal(
|
||||
journal: RestartJournal,
|
||||
event: AgentRestartJournalEvent,
|
||||
): RestartJournal {
|
||||
const key = `${event.restartId}:${event.type}`;
|
||||
const previous = journal[event.agentId] ?? [];
|
||||
if (previous.some((item) => `${item.restartId}:${item.type}` === key)) return journal;
|
||||
return {
|
||||
...journal,
|
||||
[event.agentId]: [...previous, event].slice(-12),
|
||||
};
|
||||
}
|
||||
|
||||
/** The store, or an inert default when read outside a provider. */
|
||||
function useStore(): AnnouncementsStore {
|
||||
return useContext(AnnouncementsContext) ?? DEFAULT_STORE;
|
||||
@ -183,6 +299,20 @@ export function useTargetAnnouncements(target: string): {
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgentRestart(agentId: string): {
|
||||
restart: AgentRestartPresentation | null;
|
||||
events: AgentRestartJournalEvent[];
|
||||
} {
|
||||
const { restarts, restartJournal } = useStore();
|
||||
return useMemo(
|
||||
() => ({
|
||||
restart: restarts[agentId] ?? null,
|
||||
events: restartJournal[agentId] ?? [],
|
||||
}),
|
||||
[agentId, restartJournal, restarts],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates the target's busy state from the reconciled read-model at mount/reboot,
|
||||
* so an overlay mounts for a turn already in flight when its cell appears — no live
|
||||
|
||||
Reference in New Issue
Block a user