From b445f3f0c6e369e2f7647c98d24b72cacc4ea1d6 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 3 Aug 2026 00:14:36 +0800 Subject: [PATCH 1/3] fix: start a collaboration on its goal, and stop the role editor stealing focus Two defects found while investigating a codeoid-review collaboration that showed its children in the sidebar and then did nothing. 1. A collaboration was built but never started. session.create compiled the goal into the orchestrator's constitution, brought up the role-children (deliberately silent, so a fleet of N costs zero tokens), and returned. Nothing ever took a turn. The goal sat idle with an empty transcript until the owner typed into a session that already knew exactly what it was for. Confirmed against a live instance: the parent and both children had a .meta.json and no .jsonl at all -- not one message, not even a user prompt -- with lastActivityAt equal to createdAt, while 18 other sessions had normal transcripts. The orchestrator is now sent its goal as the opening user turn. Sending the goal text rather than a bare "begin" keeps the transcript self-describing on attach and on resume. The send is fire-and-forget: a create that already spawned children must not fail on the first model call, and a failure leaves a usable idle collaboration with a log line saying so. Children stay silent as before. 2. The collaborate role editor lost focus on every keystroke. The role rows rendered through , which reconciles by item identity, while updateRole patched a row with { ...r, ...patch } -- a new object. Every character retyped the row's identity, so Solid disposed that row's DOM and built a fresh one, remounting the under the cursor. Typing a role name one character at a time was the only way through it. Switched the roles list to , which keys by slot and hands the item in as an accessor, so the input element is stable across edits. This is what is for: a fixed set of form rows whose CONTENTS change, not a keyed list that reorders. Also adjusts dispatch-host's collaboration helper, which assumed create left the orchestrator idle. It now waits for the kickoff turn to complete -- checking numTurns, not just status, because the fire-and-forget send means an immediate status read still reads idle. Co-Authored-By: Claude Opus 5 (1M context) --- src/daemon/session-manager.ts | 27 ++++++++++++ src/tests/collaboration.test.ts | 57 ++++++++++++++++++++++++++ src/tests/dispatch-host.test.ts | 22 ++++++++-- web/src/components/NewSessionModal.tsx | 29 ++++++------- 4 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index bec016f..6a7d037 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -2081,6 +2081,33 @@ mcpHub: this.#mcpHub, } } + // Kick the orchestrator off on its own goal. + // + // Everything above only BUILDS the collaboration: the goal is compiled into + // the orchestrator's constitution and the role-children are brought up + // deliberately silent (see #spawnCollaborationChildren — a fleet of N costs + // zero tokens). Nothing sent a turn, so before this the whole goal sat idle + // at "no messages, no progress" until the owner happened to type something + // into a session that already knew exactly what it was for. + // + // The goal text is sent as the opening user turn rather than a bare "begin": + // it makes the transcript self-describing (the goal is the first thing you + // read on attach, and on resume) instead of opening with a directive whose + // subject lives only in the constitution. + // + // Fire-and-forget on purpose — create must not block on the first model + // call, and a send failure has to leave a usable (if idle) collaboration + // rather than failing the create that already spawned children. + if (collaboration) { + void session.send(collaboration.goal, auth).catch((err: unknown) => { + console.error( + `[codeoid] collaboration ${session.id.slice(0, 8)} failed to start on its goal (send it a message to begin): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + } + return { type: "response.ok", requestId: msg.id, diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index 0e48c7f..6e85196 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -2106,3 +2106,60 @@ describe("collaboration.panels", () => { expect(panels.length).toBeGreaterThan(0); }); }); + +describe("collaboration auto-start", () => { + /** + * Creating a collaboration used to BUILD everything and start nothing: the + * goal was compiled into the orchestrator's constitution, the role-children + * came up silent, and then the whole goal sat at "idle" with no transcript + * until the owner typed into a session that already knew what it was for. + * Observed as a collaboration reporting its children in the sidebar while the + * centre pane stayed empty and no work ever happened. + */ + test("starts the orchestrator on its goal instead of leaving it idle", async () => { + const created: MockSessionProvider[] = []; + const registry = new ProviderRegistry("claude"); + for (const id of ["claude", "gemini"] as const) { + registry.register({ + id, + displayName: id, + create: () => { + const p = new MockSessionProvider(id, [textTurn(`${id} ok`)]); + created.push(p); + return p; + }, + }); + } + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: mkConfig(), + providers: registry, + }); + + const resp = await run({ + type: "session.create", + id: "auto-start", + name: "collab-auto", + workdir, + collaboration: VALID, + }); + expect(resp.type).toBe("response.ok"); + + // The orchestrator's provider is built first; the role-children follow. + const orchestrator = created[0]!; + const deadline = Date.now() + 2000; + while (orchestrator.capturedOpts.length === 0) { + if (Date.now() > deadline) throw new Error("orchestrator never took a turn"); + await Bun.sleep(10); + } + + // It opens on the goal itself, so the transcript is self-describing on + // attach and on resume rather than starting with a contentless directive. + expect(orchestrator.capturedOpts[0]!.userMessage).toContain(VALID.goal); + + // The children must STILL be silent — bringing up a fleet of N costs zero + // tokens, and none of them should burn a turn learning to wait. + for (const child of created.slice(1)) { + expect(child.capturedOpts).toHaveLength(0); + } + }); +}); diff --git a/src/tests/dispatch-host.test.ts b/src/tests/dispatch-host.test.ts index 27a8cd5..bcc2880 100644 --- a/src/tests/dispatch-host.test.ts +++ b/src/tests/dispatch-host.test.ts @@ -330,7 +330,20 @@ describe("dispatch host — event routing", () => { { id: "cl", auth: AUTH, send: () => {} }, ); if (resp.type !== "response.ok") throw new Error(`create failed: ${JSON.stringify(resp)}`); - return resp.data as SessionInfo; + const info = resp.data as SessionInfo; + // Creating a collaboration now starts the orchestrator on its goal, so it + // is BUSY the moment create returns. These tests are about dispatch + // routing, not about that opening turn: let it settle so each test starts + // from an idle orchestrator, the precondition they were written against. + // Wait for that turn to have RUN, not merely for the session to look idle: + // the kickoff send is fire-and-forget, so an immediate status read still + // sees "idle" before it has started, and the test would then tick the + // dispatcher into a mid-turn orchestrator and see its event held back. + await until(() => { + const s = manager._sessionForTest(info.id); + return (s?.toInfo().usage?.numTurns ?? 0) > 0 && s?.status === "idle"; + }); + return info; }; const childrenOf = async (parentId: string): Promise => { @@ -389,15 +402,16 @@ describe("dispatch host — event routing", () => { now: Date.now(), }); - expect(turnsOf(goal.id)).toBe(0); + // Baseline, not zero: the orchestrator already took its opening goal turn. + const turnsBefore = turnsOf(goal.id); await manager.dispatcher.tick(); // Delivered TO THE ORCHESTRATOR — proven by it having taken a turn, not // merely by the queue draining (a retired event drains it too). // Delivered TO THE ORCHESTRATOR — proven by a completed turn on that exact // session, not by the queue draining (a retired event drains it too). - await untilTurn(goal.id); - expect(turnsOf(goal.id)).toBeGreaterThan(0); + await until(() => turnsOf(goal.id) > turnsBefore); + expect(turnsOf(goal.id)).toBeGreaterThan(turnsBefore); expect(pending()).toHaveLength(0); }); diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx index 75e5cf2..b65c915 100644 --- a/web/src/components/NewSessionModal.tsx +++ b/web/src/components/NewSessionModal.tsx @@ -12,6 +12,7 @@ import { Component, For, + Index, Show, createEffect, createMemo, @@ -520,24 +521,24 @@ const NewSessionModal: Component = () => { Roles - + {(r, i) => { - const isOrchestrator = () => r.name.trim().toLowerCase() === "orchestrator"; + const isOrchestrator = () => r().name.trim().toLowerCase() === "orchestrator"; return (
updateRole(i(), { name: e.currentTarget.value })} + value={r().name} + onInput={(e) => updateRole(i, { name: e.currentTarget.value })} class="min-w-0 flex-1 rounded border border-border bg-bg-elev px-2 py-1 font-mono text-[12px] text-fg outline-none focus:border-accent" disabled={busy() || isOrchestrator()} aria-label="Role name" />
); }} - +