feat: mark-colony-spawned for a session that created colony

This commit is contained in:
Richard Tang
2026-04-19 17:21:06 -07:00
parent b007ed753b
commit 0d11a946a5
8 changed files with 526 additions and 11 deletions
@@ -371,6 +371,7 @@ async def llm_compact(
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
max_depth: int = LLM_COMPACT_MAX_DEPTH,
max_context_tokens: int = 128_000,
preserve_user_messages: bool = False,
) -> str:
"""Summarise *messages* with LLM, splitting recursively if too large.
@@ -378,6 +379,11 @@ async def llm_compact(
rejects the call with a context-length error, the messages are split
in half and each half is summarised independently. Tool history is
appended once at the top-level call (``_depth == 0``).
When ``preserve_user_messages`` is True, the prompt and system message
are amplified to instruct the LLM to keep every user message verbatim
and in full used by the manual /compact-and-fork endpoint where the
user wants their voice carried into the new session intact.
"""
from framework.agent_loop.conversation import extract_tool_call_history
from framework.agent_loop.internals.tool_result_handler import is_context_too_large_error
@@ -401,6 +407,7 @@ async def llm_compact(
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
else:
prompt = build_llm_compaction_prompt(
@@ -408,17 +415,30 @@ async def llm_compact(
accumulator,
formatted,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
if preserve_user_messages:
system_msg = (
"You are a conversation compactor for an AI agent. "
"Write a detailed summary that allows the agent to "
"continue its work. CRITICAL: reproduce every user "
"message verbatim and in full inside the 'User Messages' "
"section — do not paraphrase, truncate, or merge them. "
"Assistant turns and tool results may be summarised, but "
"user input is sacred."
)
else:
system_msg = (
"You are a conversation compactor for an AI agent. "
"Write a detailed summary that allows the agent to "
"continue its work. Preserve user-stated rules, "
"constraints, and account/identity preferences verbatim."
)
summary_budget = max(1024, max_context_tokens // 2)
try:
response = await ctx.llm.acomplete(
messages=[{"role": "user", "content": prompt}],
system=(
"You are a conversation compactor for an AI agent. "
"Write a detailed summary that allows the agent to "
"continue its work. Preserve user-stated rules, "
"constraints, and account/identity preferences verbatim."
),
system=system_msg,
max_tokens=summary_budget,
)
summary = response.content
@@ -437,6 +457,7 @@ async def llm_compact(
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
else:
raise
@@ -459,6 +480,7 @@ async def _llm_compact_split(
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
max_depth: int = LLM_COMPACT_MAX_DEPTH,
max_context_tokens: int = 128_000,
preserve_user_messages: bool = False,
) -> str:
"""Split messages in half and summarise each half independently."""
mid = max(1, len(messages) // 2)
@@ -470,6 +492,7 @@ async def _llm_compact_split(
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
s2 = await llm_compact(
ctx,
@@ -479,6 +502,7 @@ async def _llm_compact_split(
char_limit=char_limit,
max_depth=max_depth,
max_context_tokens=max_context_tokens,
preserve_user_messages=preserve_user_messages,
)
return s1 + "\n\n" + s2
@@ -510,6 +534,7 @@ def build_llm_compaction_prompt(
formatted_messages: str,
*,
max_context_tokens: int = 128_000,
preserve_user_messages: bool = False,
) -> str:
"""Build prompt for LLM compaction targeting 50% of token budget.
@@ -539,6 +564,18 @@ def build_llm_compaction_prompt(
target_chars = target_tokens * 4
node_ctx = "\n".join(ctx_lines)
user_messages_section = (
"6. **User Messages** — Reproduce EVERY user message verbatim and "
"in full, in chronological order, each on its own line prefixed "
"with the message index (e.g. \"[U1] ...\"). Do NOT paraphrase, "
"summarise, merge, or omit any user message. Preserve markdown, "
"code fences, whitespace, and punctuation exactly as the user "
"wrote them.\n"
if preserve_user_messages
else "6. **User Messages** — Preserve ALL user-stated rules, constraints, "
"identity preferences, and account details verbatim.\n"
)
return (
"You are compacting an AI agent's conversation history. "
"The agent is still working and needs to continue.\n\n"
@@ -559,8 +596,7 @@ def build_llm_compaction_prompt(
"resolved. Include root causes so the agent doesn't repeat them.\n"
"5. **Problem Solving Efforts** — Approaches tried, dead ends hit, "
"and reasoning behind the current strategy.\n"
"6. **User Messages** — Preserve ALL user-stated rules, constraints, "
"identity preferences, and account details verbatim.\n"
f"{user_messages_section}"
"7. **Pending Tasks** — Work remaining, outputs still needed, and "
"any blockers.\n"
"8. **Current Work** — The most recent action taken and the immediate "
+283
View File
@@ -217,6 +217,25 @@ async def handle_chat(request: web.Request) -> web.Response:
logger.debug("[handle_chat] Session resolution failed: %s", err)
return err
# Sessions that have spawned a colony are locked: the user must compact +
# fork into a fresh session before continuing the conversation. Frontend
# surfaces this as a button instead of the textarea, but enforce server-
# side too so the lock can't be bypassed by a stale tab or scripted call.
if getattr(session, "colony_spawned", False):
return web.json_response(
{
"error": "session_locked",
"reason": "colony_spawned",
"spawned_colony_name": getattr(session, "spawned_colony_name", None),
"message": (
"This session is locked because a colony has been "
"spawned from it. Compact and start a new session "
"with the same queen to continue."
),
},
status=409,
)
body = await request.json()
message = body.get("message", "")
display_message = body.get("display_message")
@@ -663,6 +682,262 @@ async def handle_cancel_queen(request: web.Request) -> web.Response:
return web.json_response({"cancelled": True})
async def handle_mark_colony_spawned(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/mark-colony-spawned -- lock the queen DM.
Called by the frontend the first time the user clicks the
COLONY_CREATED system message. Persists ``colony_spawned: true`` and
``spawned_colony_name`` into the queen session's ``meta.json`` so the
lock survives server restart, and caches the same on the live Session
object so subsequent /chat calls in this process can be rejected
immediately without disk I/O.
Body: ``{"colony_name": "..."}``
"""
from datetime import datetime as _dt
session, err = resolve_session(request)
if err:
return err
body = await request.json() if request.can_read_body else {}
colony_name = (body.get("colony_name") or "").strip()
if not colony_name:
return web.json_response({"error": "colony_name is required"}, status=400)
queen_dir = getattr(session, "queen_dir", None)
if queen_dir is None:
return web.json_response(
{"error": "queen session directory is not set on this session"},
status=503,
)
meta_path = queen_dir / "meta.json"
meta: dict = {}
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
meta = {}
meta["colony_spawned"] = True
meta["spawned_colony_name"] = colony_name
meta["spawned_colony_at"] = _dt.now(UTC).isoformat()
try:
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(meta), encoding="utf-8")
except OSError as exc:
logger.exception("mark_colony_spawned: failed to persist meta.json")
return web.json_response({"error": f"failed to persist: {exc}"}, status=500)
session.colony_spawned = True
session.spawned_colony_name = colony_name
return web.json_response(
{
"session_id": session.id,
"colony_spawned": True,
"spawned_colony_name": colony_name,
}
)
async def handle_compact_and_fork(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/compact-and-fork -- compact + new session.
The locked-by-colony-spawn UI calls this when the user clicks "compact
+ start a new session with the same queen". The flow:
1. Read the queen's full conversation off disk.
2. Run the LLM compactor with ``preserve_user_messages=True`` so every
user turn survives verbatim into the summary.
3. Mint a fresh session ID, copy the old queen-session dir to the new
location, then replace the conversation parts with a single
summary message and reset the cursor. The new session starts with
a clean event log.
4. Spin up a live session bound to the new dir using the same queen
identity. The OLD session stays alive but locked; the user
navigates to the new session in the response.
"""
import asyncio
import shutil
import time as _time
from datetime import datetime as _dt
from framework.agent_loop.conversation import Message
from framework.agent_loop.internals.compaction import llm_compact
from framework.agent_loop.types import AgentContext
from framework.server.session_manager import (
_generate_session_id,
_queen_session_dir,
)
from framework.storage.conversation_store import FileConversationStore
session, err = resolve_session(request)
if err:
return err
queen_dir = getattr(session, "queen_dir", None)
if queen_dir is None or not queen_dir.exists():
return web.json_response(
{"error": "queen session directory not found"},
status=404,
)
queen_executor = getattr(session, "queen_executor", None)
if queen_executor is None:
return web.json_response({"error": "queen is not running"}, status=503)
queen_node = queen_executor.node_registry.get("queen") if queen_executor else None
queen_ctx: AgentContext | None = getattr(queen_node, "_last_ctx", None) if queen_node else None
if queen_ctx is None or queen_ctx.llm is None:
return web.json_response(
{
"error": (
"queen context not yet stamped (no LLM available for "
"compaction). Send a message to the queen and retry."
)
},
status=503,
)
queen_name = session.queen_name or "default"
convs_dir = queen_dir / "conversations"
if not convs_dir.exists():
return web.json_response(
{"error": "queen has no conversation history yet"},
status=400,
)
src_store = FileConversationStore(convs_dir)
raw_parts = await src_store.read_parts()
messages: list[Message] = []
for part in raw_parts:
try:
messages.append(Message.from_storage_dict(part))
except (KeyError, TypeError):
# Skip malformed parts rather than failing the whole compaction;
# the summary will note the gap if needed.
logger.warning("compact_and_fork: skipping malformed part %r", part)
continue
if not messages:
return web.json_response(
{"error": "queen conversation is empty -- nothing to compact"},
status=400,
)
# Run the LLM compactor with preserve_user_messages so the new session
# carries every user turn forward. Failures here are user-visible
# because the whole point of the action is the compacted summary.
try:
max_ctx_tokens = 180_000
loop_cfg = getattr(queen_node, "_config", None)
if loop_cfg is not None and getattr(loop_cfg, "max_context_tokens", None):
max_ctx_tokens = int(loop_cfg.max_context_tokens)
summary = await llm_compact(
queen_ctx,
messages,
accumulator=None,
max_context_tokens=max_ctx_tokens,
preserve_user_messages=True,
)
except Exception as exc:
logger.exception("compact_and_fork: llm_compact failed")
return web.json_response(
{"error": f"compaction failed: {exc}"},
status=500,
)
new_session_id = _generate_session_id()
new_dir = _queen_session_dir(new_session_id, queen_name)
if new_dir.exists():
# Defensively: same-second collision would clobber another session.
return web.json_response(
{"error": f"new session dir collision: {new_dir}"},
status=500,
)
try:
await asyncio.to_thread(shutil.copytree, queen_dir, new_dir)
except OSError as exc:
logger.exception("compact_and_fork: copytree failed")
return web.json_response(
{"error": f"failed to fork session dir: {exc}"},
status=500,
)
# Replace parts/ with the single compacted summary; clear partials and
# the event log so the new session presents a clean SSE replay.
new_parts_dir = new_dir / "conversations" / "parts"
new_partials_dir = new_dir / "conversations" / "partials"
new_events_path = new_dir / "events.jsonl"
def _cleanup_forked_dir() -> None:
if new_parts_dir.exists():
shutil.rmtree(new_parts_dir)
if new_partials_dir.exists():
shutil.rmtree(new_partials_dir)
if new_events_path.exists():
new_events_path.unlink()
try:
await asyncio.to_thread(_cleanup_forked_dir)
except OSError:
logger.warning("compact_and_fork: cleanup of forked dir partial-failed", exc_info=True)
# Write the summary as message seq 0 so the new queen wakes up with a
# single user-role primer that contains everything she needs.
summary_msg = Message(seq=0, role="user", content=summary)
dest_store = FileConversationStore(new_dir / "conversations")
await dest_store.write_part(0, summary_msg.to_storage_dict())
await dest_store.write_cursor({"next_seq": 1})
# Update meta.json: clear the lock and record provenance.
new_meta_path = new_dir / "meta.json"
new_meta: dict = {}
if new_meta_path.exists():
try:
new_meta = json.loads(new_meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
new_meta = {}
new_meta.pop("colony_spawned", None)
new_meta.pop("spawned_colony_name", None)
new_meta.pop("spawned_colony_at", None)
new_meta["queen_id"] = queen_name
new_meta["compacted_from"] = session.id
new_meta["compacted_at"] = _dt.now(UTC).isoformat()
new_meta["created_at"] = _time.time()
try:
new_meta_path.write_text(json.dumps(new_meta), encoding="utf-8")
except OSError:
logger.warning("compact_and_fork: failed to write new meta.json", exc_info=True)
manager: Any = request.app["manager"]
try:
new_session = await manager.create_session(
session_id=None,
queen_resume_from=new_session_id,
queen_name=queen_name,
initial_phase="independent",
)
except Exception as exc:
logger.exception("compact_and_fork: create_session failed for forked id %s", new_session_id)
return web.json_response(
{"error": f"failed to start forked session: {exc}"},
status=500,
)
return web.json_response(
{
"new_session_id": new_session.id,
"queen_id": queen_name,
"compacted_from": session.id,
"summary_chars": len(summary),
"messages_compacted": len(messages),
}
)
async def handle_colony_spawn(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/colony-spawn -- fork queen session into a colony.
@@ -1085,3 +1360,11 @@ def register_routes(app: web.Application) -> None:
app.router.add_post("/api/sessions/{session_id}/replay", handle_replay)
app.router.add_get("/api/sessions/{session_id}/goal-progress", handle_goal_progress)
app.router.add_post("/api/sessions/{session_id}/colony-spawn", handle_colony_spawn)
app.router.add_post(
"/api/sessions/{session_id}/mark-colony-spawned",
handle_mark_colony_spawned,
)
app.router.add_post(
"/api/sessions/{session_id}/compact-and-fork",
handle_compact_and_fork,
)
+2
View File
@@ -63,6 +63,8 @@ def _session_to_live_dict(session) -> dict:
"queen_supports_images": supports_image_tool_results(queen_model) if queen_model else True,
"queen_id": getattr(phase_state, "queen_id", None) if phase_state else None,
"queen_name": (phase_state.queen_profile or {}).get("name") if phase_state else None,
"colony_spawned": getattr(session, "colony_spawned", False),
"spawned_colony_name": getattr(session, "spawned_colony_name", None),
}
+21
View File
@@ -111,6 +111,12 @@ class Session:
# tool unlocked. The mode is the canonical discriminator for storage
# path, tool exposure, and SSE filtering — see the Phase 2 plan.
mode: Literal["dm", "colony"] = "dm"
# Set to True after the user clicks the COLONY_CREATED system message
# in this DM. Locks the chat input — the user must compact+fork into a
# fresh session before continuing the conversation. Persisted in
# meta.json so the lock survives server restarts.
colony_spawned: bool = False
spawned_colony_name: str | None = None
class SessionManager:
@@ -1319,6 +1325,13 @@ class SessionManager:
_new_meta["agent_path"] = str(session.worker_path)
_existing_meta.update(_new_meta)
_meta_path.write_text(json.dumps(_existing_meta), encoding="utf-8")
# Hydrate colony-spawned lock state from meta.json so the lock
# survives server restart / cold-resume into a live session.
if _existing_meta.get("colony_spawned") is True:
session.colony_spawned = True
_spawned_name = _existing_meta.get("spawned_colony_name")
if isinstance(_spawned_name, str):
session.spawned_colony_name = _spawned_name
except OSError:
pass
@@ -1694,6 +1707,8 @@ class SessionManager:
# Read extra metadata written at session start
agent_name: str | None = None
agent_path: str | None = None
colony_spawned: bool = False
spawned_colony_name: str | None = None
meta_path = queen_dir / "meta.json"
if meta_path.exists():
try:
@@ -1701,6 +1716,10 @@ class SessionManager:
agent_name = meta.get("agent_name")
agent_path = meta.get("agent_path")
created_at = meta.get("created_at") or created_at
colony_spawned = bool(meta.get("colony_spawned"))
_spawned = meta.get("spawned_colony_name")
if isinstance(_spawned, str):
spawned_colony_name = _spawned
except (json.JSONDecodeError, OSError):
pass
@@ -1712,6 +1731,8 @@ class SessionManager:
"created_at": created_at,
"agent_name": agent_name,
"agent_path": agent_path,
"colony_spawned": colony_spawned,
"spawned_colony_name": spawned_colony_name,
}
@staticmethod
+24
View File
@@ -74,4 +74,28 @@ export const executionApi = {
`/sessions/${sessionId}/colony-spawn`,
{ colony_name: colonyName, task },
),
/** Lock a queen DM session because the user opened a spawned colony.
* After this call /chat returns 409 until compactAndFork creates a new session.
*/
markColonySpawned: (sessionId: string, colonyName: string) =>
api.post<{
session_id: string;
colony_spawned: boolean;
spawned_colony_name: string;
}>(`/sessions/${sessionId}/mark-colony-spawned`, {
colony_name: colonyName,
}),
/** Compact the locked session and fork into a fresh session under the same queen.
* Returns the new session ID; the frontend should navigate the user to it.
*/
compactAndFork: (sessionId: string) =>
api.post<{
new_session_id: string;
queen_id: string;
compacted_from: string;
summary_chars: number;
messages_compacted: number;
}>(`/sessions/${sessionId}/compact-and-fork`),
};
+5
View File
@@ -20,6 +20,11 @@ export interface LiveSession {
queen_id?: string | null;
/** Selected queen display name (e.g. "Alexandra") */
queen_name?: string | null;
/** True after the user has clicked into a colony spawned from this DM —
* /chat returns 409 until the user compacts and forks into a new session. */
colony_spawned?: boolean;
/** Name of the colony that locked this session (set with colony_spawned). */
spawned_colony_name?: string | null;
/** Present in 409 conflict responses when worker is still loading */
loading?: boolean;
}
+70 -3
View File
@@ -120,6 +120,22 @@ interface ChatPanelProps {
queenProfileId?: string | null;
/** Queen ID — used to display the queen's avatar photo in messages */
queenId?: string;
/** Called when the user clicks a `colony_link` system message. Receives
* the colony name. The parent should call markColonySpawned + flip
* ``colonySpawned`` to lock the input. The Link still navigates. */
onColonyLinkClick?: (colonyName: string) => void;
/** When true, the composer is replaced with a "compact + new session"
* button — set by the parent after the user opens a spawned colony. */
colonySpawned?: boolean;
/** Name of the colony that locked this DM (shown on the locked button). */
spawnedColonyName?: string | null;
/** Display label for the queen on the locked button (e.g. "Charlotte"). */
queenDisplayName?: string;
/** Called when the user clicks the locked-state button. Should compact
* the current session and navigate to the new one. */
onCompactAndFork?: () => void;
/** When true, disable the compact-and-fork button (request in flight). */
compactingAndForking?: boolean;
}
const queenColor = "hsl(45,95%,58%)";
@@ -482,12 +498,14 @@ const MessageBubble = memo(
showQueenPhaseBadge = true,
queenProfileId,
queenAvatarUrl,
onColonyLinkClick,
}: {
msg: ChatMessage;
queenPhase?: "independent" | "working" | "reviewing";
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
queenAvatarUrl?: string | null;
onColonyLinkClick?: (colonyName: string) => void;
}) {
const isUser = msg.type === "user";
const isQueen = msg.role === "queen";
@@ -535,7 +553,9 @@ const MessageBubble = memo(
if (msg.type === "colony_link") {
// Rendered when the queen calls create_colony() and the backend
// emits a COLONY_CREATED event. Gives the user a clickable card
// that navigates to the new colony page.
// that navigates to the new colony page. Clicking also locks the
// queen DM (mark-colony-spawned) so the user must compact + fork
// before continuing this conversation.
let parsed: {
colony_name?: string;
is_new?: boolean;
@@ -557,6 +577,11 @@ const MessageBubble = memo(
<div className="flex justify-center py-2">
<Link
to={href}
onClick={() => {
if (colonyName && onColonyLinkClick) {
onColonyLinkClick(colonyName);
}
}}
className="inline-flex items-center gap-2 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 px-4 py-2 rounded-full border border-primary/20 transition-colors"
>
<span>🏛</span>
@@ -711,6 +736,12 @@ export default function ChatPanel({
initialDraft,
queenProfileId,
queenId,
onColonyLinkClick,
colonySpawned,
spawnedColonyName,
queenDisplayName,
onCompactAndFork,
compactingAndForking,
}: ChatPanelProps) {
const [input, setInput] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
@@ -1151,6 +1182,7 @@ export default function ChatPanel({
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
onColonyLinkClick={onColonyLinkClick}
/>
</div>
);
@@ -1308,8 +1340,43 @@ export default function ChatPanel({
);
})()}
{/* Input area — question widget replaces textarea when a question is pending */}
{pendingQuestions &&
{/* Input area — colony-spawned lock replaces everything; question widget
replaces textarea when a question is pending */}
{colonySpawned ? (
<div className="p-4 border-t border-border/50 bg-muted/20">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-xs text-muted-foreground max-w-md">
This conversation spawned colony{" "}
{spawnedColonyName ? (
<strong className="text-foreground">{spawnedColonyName}</strong>
) : (
"a colony"
)}
. To keep chatting with{" "}
{queenDisplayName || "this queen"}, compact this session and start
a fresh one.
</p>
<button
type="button"
onClick={onCompactAndFork}
disabled={!onCompactAndFork || compactingAndForking}
className="inline-flex items-center gap-2 text-xs font-medium text-primary-foreground bg-primary hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-2 rounded-full transition-opacity"
>
{compactingAndForking ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Compacting</span>
</>
) : (
<span>
Compact & start new session
{queenDisplayName ? ` with ${queenDisplayName}` : ""}
</span>
)}
</button>
</div>
</div>
) : pendingQuestions &&
pendingQuestions.length >= 2 &&
onMultiQuestionSubmit ? (
<MultiQuestionWidget
+77
View File
@@ -59,6 +59,15 @@ export default function QueenDM() {
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
const [cloneColonyName, setCloneColonyName] = useState("");
const [cloneTask, setCloneTask] = useState("");
// Colony-spawned lock state. Once a colony has been spawned from this DM
// and the user clicked into it, /chat is rejected server-side and the
// composer is replaced with a "compact + new session" button. Hydrated
// from the session detail and updated optimistically on click.
const [colonySpawned, setColonySpawned] = useState(false);
const [spawnedColonyName, setSpawnedColonyName] = useState<string | null>(
null,
);
const [compactingAndForking, setCompactingAndForking] = useState(false);
const turnCounterRef = useRef(0);
// Maps tool_use_id → the pill message ID and tool name that was created for it.
@@ -85,6 +94,9 @@ export default function QueenDM() {
setActiveToolCalls({});
setQueenPhase("independent");
setInitialDraft(null);
setColonySpawned(false);
setSpawnedColonyName(null);
setCompactingAndForking(false);
turnCounterRef.current = 0;
toolUseToPillRef.current = {};
queenIterTextRef.current = {};
@@ -305,6 +317,65 @@ export default function QueenDM() {
};
}, [queenId, sessionId]);
// Hydrate the colony-spawned lock from the session detail whenever the
// session ID changes. The /sessions/{id} response carries colony_spawned
// (live) and the cold-info path returns the same field after a server
// restart, so the same fetch covers both states.
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
sessionsApi
.get(sessionId)
.then((data) => {
if (cancelled) return;
const locked = Boolean(
(data as { colony_spawned?: boolean }).colony_spawned,
);
const name =
(data as { spawned_colony_name?: string | null })
.spawned_colony_name ?? null;
setColonySpawned(locked);
setSpawnedColonyName(name);
})
.catch(() => {
// Non-fatal — lock simply won't activate until the user navigates back.
});
return () => {
cancelled = true;
};
}, [sessionId]);
const handleColonyLinkClick = useCallback(
(colonyName: string) => {
if (!sessionId || !colonyName) return;
// Optimistically lock so the textarea swaps to the button before the
// user navigates back. Backend persists the same flag in meta.json so
// a refresh would re-hydrate the locked state anyway.
setColonySpawned(true);
setSpawnedColonyName(colonyName);
executionApi.markColonySpawned(sessionId, colonyName).catch(() => {
// Revert on failure so the user isn't stranded with no composer.
setColonySpawned(false);
setSpawnedColonyName(null);
});
},
[sessionId],
);
const handleCompactAndFork = useCallback(async () => {
if (!sessionId || compactingAndForking || !queenId) return;
setCompactingAndForking(true);
try {
const result = await executionApi.compactAndFork(sessionId);
// Navigate to the freshly-forked session for the same queen. Replacing
// the URL keeps the back button on the home/history page rather than
// bouncing back to the now-locked DM.
setSearchParams({ session: result.new_session_id }, { replace: true });
} catch {
setCompactingAndForking(false);
}
}, [sessionId, compactingAndForking, queenId, setSearchParams]);
const handleColonySpawn = useCallback(async () => {
if (!sessionId || spawning) return;
const colony = cloneColonyName.trim();
@@ -856,6 +927,12 @@ export default function QueenDM() {
initialDraft={initialDraft}
queenProfileId={queenId ?? null}
queenId={queenId}
onColonyLinkClick={handleColonyLinkClick}
colonySpawned={colonySpawned}
spawnedColonyName={spawnedColonyName}
queenDisplayName={queenName}
onCompactAndFork={handleCompactAndFork}
compactingAndForking={compactingAndForking}
/>
</div>