Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cac07f6e | |||
| f472e0f782 |
@@ -145,70 +145,21 @@ public final class SessionManager implements TurnListener {
|
||||
|
||||
/** Tear a worker down by pane id and remove it from the registry. Idempotent. */
|
||||
public void release(String paneId) {
|
||||
release(paneId, ReleaseCause.COMPLETED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core teardown: always stops the worker pane and deregisters the session; whether the worker's
|
||||
* git worktree is also removed depends on {@code cause}.
|
||||
*
|
||||
* <p>CB-544: these are two concerns that used to be fused. Stopping the pane is correct on every
|
||||
* teardown — the worker process must end. Removing the worktree is a destructive act that is only
|
||||
* correct for a deliberately-finished teardown (an explicit stop of a completed session, the
|
||||
* reaper releasing a genuinely idle one, a context-capped or recycled session). A shutdown drain
|
||||
* must stop panes but preserve worktrees: a worker's uncommitted work exists in exactly one
|
||||
* place — its worktree — so deleting it while the daemon simply goes down is silent data loss,
|
||||
* with no copy and no error. Do NOT fuse these back together; the cost of an orphaned worktree
|
||||
* is a logged path an operator can reclaim, the cost of a deleted one is unrecoverable work.
|
||||
*/
|
||||
private void release(String paneId, ReleaseCause cause) {
|
||||
WorkerSession removed = registry.remove(paneId);
|
||||
boolean preserveWorktree = cause == ReleaseCause.SHUTDOWN;
|
||||
if (removed != null) {
|
||||
log.debug("releasing session pane={} terminal={} state={} cause={}",
|
||||
removed.paneId(), removed.terminalId(), removed.state(), cause);
|
||||
if (preserveWorktree && removed.worktree() != null) {
|
||||
logPreservedForShutdown(removed);
|
||||
}
|
||||
log.debug("releasing session pane={} terminal={} state={}",
|
||||
removed.paneId(), removed.terminalId(), removed.state());
|
||||
// CB-516: a send still waiting on this worker can never be answered now. Tell the
|
||||
// listener BEFORE the pane is torn down, so a blocked caller fails fast with a real
|
||||
// reason instead of sitting on a rendezvous nothing will ever resolve.
|
||||
notifyReleased(removed.terminalId());
|
||||
}
|
||||
launcher.stop(paneId);
|
||||
if (removed != null && !preserveWorktree && removed.worktree() != null) {
|
||||
if (removed != null && removed.worktree() != null) {
|
||||
worktrees.remove(worktrees.repoRoot(removed.cwd()), removed.worktree());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a session is being released — governs whether its worktree is preserved or removed.
|
||||
* Worktree removal is reserved for the one case that is genuinely finished; everything else
|
||||
* must keep the worker's only copy of its work.
|
||||
*/
|
||||
public enum ReleaseCause {
|
||||
/** Deliberate teardown of a finished session. Stops the pane and removes the worktree. */
|
||||
COMPLETED,
|
||||
/** Daemon shutdown drain. Stops the pane but PRESERVES the worktree. */
|
||||
SHUTDOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-544 shutdown drain log for a worktree we deliberately kept. A session still {@code BUSY}
|
||||
* when the drain timeout expired was abandoned mid-turn — that work may be uncommitted and is
|
||||
* the only copy — so the message is loud and points at the path an operator needs to reclaim.
|
||||
*/
|
||||
private void logPreservedForShutdown(WorkerSession session) {
|
||||
if (session.state() == WorkerSession.State.BUSY) {
|
||||
log.warn("shutdown drain abandoned BUSY session pane={} terminal={} mid-turn; "
|
||||
+ "worktree preserved at {}", session.paneId(), session.terminalId(),
|
||||
session.worktree());
|
||||
} else {
|
||||
log.info("shutdown drain preserved worktree at {} for pane={}",
|
||||
session.worktree(), session.paneId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback invoked with a session's {@code terminalId} whenever it is acquired
|
||||
* (CB-520). This is the hook that lets the reply inbox {@code own} a target's queue.
|
||||
@@ -487,16 +438,10 @@ public final class SessionManager implements TurnListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully drain all registered sessions on daemon shutdown. For each session that is
|
||||
* {@code BUSY}, poll up to {@code timeoutNanos} for it to leave {@code BUSY}, then release it
|
||||
* regardless. Non-busy sessions are released immediately. A failure releasing one session is
|
||||
* logged and does not abort the rest.
|
||||
*
|
||||
* <p>CB-544: this is a {@link ReleaseCause#SHUTDOWN} release — the worker's pane is stopped
|
||||
* (the process must end) but its worktree is preserved and its path logged. Shutdown is never
|
||||
* a reason to delete a worker's only copy of its uncommitted work. A session still {@code BUSY}
|
||||
* when the timeout expired is abandoned mid-turn and logged loudly so an operator can find its
|
||||
* kept worktree.
|
||||
* Gracefully drain all registered sessions. For each session that is {@code BUSY}, poll up to
|
||||
* {@code timeoutNanos} for it to leave {@code BUSY}, then release it regardless. Non-busy
|
||||
* sessions are released immediately. A failure releasing one session is logged and does not
|
||||
* abort the rest.
|
||||
*/
|
||||
void drainAll(long timeoutNanos) {
|
||||
long deadline = System.nanoTime() + timeoutNanos;
|
||||
@@ -517,7 +462,7 @@ public final class SessionManager implements TurnListener {
|
||||
}
|
||||
}
|
||||
}
|
||||
release(s.paneId(), ReleaseCause.SHUTDOWN);
|
||||
release(s.paneId());
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("drain failed for pane={}; continuing with remaining sessions", s.paneId(), e);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -126,42 +125,6 @@ class WorktreeSessionManagerTest {
|
||||
assertTrue(sessions.get(paneId).isEmpty(), "released session is no longer retrievable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void drainAllPreservesWorktreeOfIdleSession() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||
SessionManager sessions = new SessionManager(workerService(herdr), worktrees);
|
||||
WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
|
||||
new WorktreeRequest("cb-544", null));
|
||||
sessions.asPresence().markPresent(s.terminalId()); // READY (idle)
|
||||
|
||||
sessions.drainAll(TimeUnit.MILLISECONDS.toNanos(100));
|
||||
|
||||
assertTrue(herdr.called("pane.close"), "shutdown drain still stops the worker pane");
|
||||
assertTrue(worktrees.removeCalls().isEmpty(),
|
||||
"shutdown drain must NOT remove the worktree — it is the only copy of the work");
|
||||
assertTrue(sessions.roster().isEmpty(), "shutdown drain still deregisters the session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void drainAllPreservesWorktreeOfSessionStillBusyAtTimeout() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
||||
SessionManager sessions = new SessionManager(workerService(herdr), worktrees);
|
||||
WorkerSession s = sessions.acquire("ltms-local", null, "/caller/proj", null,
|
||||
new WorktreeRequest("cb-544", null));
|
||||
String terminal = s.terminalId();
|
||||
sessions.asPresence().markPresent(terminal);
|
||||
sessions.onDelivered(terminal); // BUSY, never completes → still BUSY when the timeout hits
|
||||
|
||||
sessions.drainAll(TimeUnit.MILLISECONDS.toNanos(100));
|
||||
|
||||
assertTrue(herdr.called("pane.close"), "shutdown drain stops the worker pane");
|
||||
assertTrue(worktrees.removeCalls().isEmpty(),
|
||||
"a session still BUSY at timeout must preserve its worktree unconditionally");
|
||||
assertTrue(sessions.roster().isEmpty(), "shutdown drain still deregisters the session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedTreeReleaseMakesNoWorktreesCalls() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# CB-500 — Multi-Tier Coordination (Stage 6)
|
||||
|
||||
**Status:** design note (proposal — ticket split deferred)
|
||||
**Status:** design note. Developments A/B remain proposals; Development C (§6 and Figures 7–8) is
|
||||
**SUPERSEDED** by the advisory-architect design in Gitea issue #16 and the `architects:` configuration
|
||||
block (CB-548).
|
||||
**Depends on:** CB-401/402 (Peer Launcher SPI + composite router — placement-neutral spawn),
|
||||
CB-308 (per-agent broker channels + global id + federated roster — the addressing substrate),
|
||||
CB-307 (durable inbox + push loop), CB-301/303 (session FSM + context-cap/idle-ttl), CB-304
|
||||
@@ -16,8 +18,9 @@ workers) into a **multi-tier** one, along three axes the lead has asked for:
|
||||
1. **Sandboxed workers** — each worker runs in a **separated, peer-owned sandbox** carrying its own
|
||||
toolchain (Claude routed via `ANTHROPIC_BASE_URL`, a headless IDE, git, MCP, dev-tools), with
|
||||
**per-role** sandboxes (a backend-agent image, a frontend-agent image).
|
||||
2. **Main-agent pairs** — the "main" tier becomes a **pair** (on-subscription Opus + one cloud
|
||||
module) collaborating, instead of a lone primary.
|
||||
2. **Main-agent pairs** — **SUPERSEDED.** The considered model made the "main" tier a pair
|
||||
(on-subscription Opus + one cloud module). The actual fleet is one human-driven lead plus two
|
||||
short-lived advisory architects on different model families.
|
||||
3. **An orchestrator tier** — a supervisor **above** the mains that owns their **session identity**
|
||||
(naming, resume) and **curates context**, so every main→worker delegation carries the *exact*
|
||||
slice of context it needs and nothing else.
|
||||
@@ -63,6 +66,10 @@ Four concrete bake-ins assume a single tier:
|
||||
|
||||
## 3. Target multi-tier architecture
|
||||
|
||||
> **SUPERSEDED fleet sketch.** Figure 2 records the former two-main model. The actual fleet is one
|
||||
> lead, two independent advisory architects, and N workers; architects are sideways peers, not leads
|
||||
> and not a tier above the lead. See Gitea issue #16 and the `architects:` block.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
human["human"]
|
||||
@@ -93,10 +100,9 @@ flowchart TB
|
||||
chan --- roster
|
||||
```
|
||||
|
||||
*Figure 2 — three tiers. Tier 0 owns the mains' session identity + context scope; Tier 1 is a
|
||||
collaborating pair, each an MCP client with its own pull inbox; Tier 2 is peer-owned sandboxes the
|
||||
bus launches into. The middle is CB-308's per-agent-channel + federated-roster substrate, now
|
||||
carrying tier-to-tier traffic, not just host-to-host.*
|
||||
*Figure 2 — **SUPERSEDED historical fleet sketch.** It proposed a collaborating pair of managed mains.
|
||||
The actual fleet keeps one human-driven lead and uses two independent, short-lived advisory architects
|
||||
on different model families, so agreement is evidence rather than correlated echo.*
|
||||
|
||||
The recursion is the key idea: **`orchestrator : mains :: main : workers`** — the same
|
||||
spawn/name/resume/scope verbs at two levels.
|
||||
@@ -166,6 +172,11 @@ gateway, because herdr keystroke-injection needs a locally-owned PTY.**
|
||||
|
||||
## 5. Development B — Main-agent pairs
|
||||
|
||||
> **SUPERSEDED — do not implement this model.** The two-main fleet was replaced by one human-driven
|
||||
> lead and two independent advisory architects. They are deliberately different model families (Claude
|
||||
> Sonnet 5 and GPT-5.6 through opencode), receive the same brief, and work independently so agreement
|
||||
> is evidence rather than correlated echo. See Gitea issue #16 and `architects:`.
|
||||
|
||||
Both mains are MCP **clients**, so **neither can be called into** — each needs a **pull-based
|
||||
per-agent inbox**, which is precisely CB-308 item #1 (per-agent AMQP channels). The primary machinery
|
||||
that is singular today (single-slot `PrimaryRegistry`, a push-loop aimed at one terminal, "these
|
||||
@@ -221,6 +232,19 @@ push-loop fan-out; relax "orchestration tools only the primary calls" to "any re
|
||||
|
||||
## 6. Development C — Orchestrator tier
|
||||
|
||||
> **SUPERSEDED — do not implement this model.** The operator rejected a supervisor above the lead.
|
||||
> The human continues to drive the pre-existing lead directly; bridged neither spawns nor resumes that
|
||||
> lead. What replaced this proposal is **one lead, two short-lived advisory architects, and N workers**:
|
||||
> the lead engages architects sideways for a strong-model assessment, then discards them. Architect
|
||||
> slots are declared in `architects:` (see Gitea issue #16), rather than making leads managed sessions.
|
||||
> The two architects deliberately use different model families — Claude Sonnet 5 and GPT-5.6 through
|
||||
> opencode — and receive the same brief independently. Agreement is evidence, not correlated echo
|
||||
> from one provider or one conversation.
|
||||
|
||||
> **Historical alternative retained.** The text and figures below record the considered model and why it
|
||||
> was rejected: it re-rooted the human-facing session above the lead, violating the still-true premise
|
||||
> that configured leaders pre-exist, are recognised, and cannot be resumed by bridged.
|
||||
|
||||
The orchestrator is **`SessionManager` recursed one tier up**: today it spawns/names/reaps *worker*
|
||||
sessions; the orchestrator does the same for *main* sessions, and adds **context scoping**.
|
||||
|
||||
@@ -249,11 +273,10 @@ flowchart TB
|
||||
m2 -->|"scoped delegation"| w
|
||||
```
|
||||
|
||||
*Figure 7 — the recursion. Tiers 1 and 2 run the identical spawn/name/resume machinery; the
|
||||
orchestrator merely operates it one level higher. **Re-rooting caveat:** today the primary IS the
|
||||
human's live session; here the human drives the orchestrator, and the mains become managed,
|
||||
resumable sessions. That moves the human-facing top up a tier — an intentional re-root, not an
|
||||
add-on.*
|
||||
*Figure 7 — **SUPERSEDED historical alternative.** The recursion re-rooted the human-facing session:
|
||||
the human drove an orchestrator and the mains became managed, resumable sessions. The operator rejected
|
||||
that re-root. The replacement keeps the human-driven, pre-existing lead and engages architects sideways
|
||||
as short-lived advisory peers; see Gitea issue #16 and `architects:`.*
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -270,10 +293,9 @@ sequenceDiagram
|
||||
O->>O: fold into orchestrator context, pick next main/turn
|
||||
```
|
||||
|
||||
*Figure 8 — context focus. The orchestrator holds the global context and hands each main only the
|
||||
slice a given delegation needs, so the main→worker conversation stays on-point. Context *scoping* is
|
||||
coordination (the bus already owns session/turn lifecycle) — it stays inside the identity boundary
|
||||
(§7), unlike toolchain ownership which does not.*
|
||||
*Figure 8 — **SUPERSEDED historical alternative.** This proposed an orchestrator holding global context
|
||||
and slicing it for managed mains. The replacement has the human-driven lead send the same advisory brief
|
||||
issue #16 and `architects:`.*
|
||||
|
||||
**Deltas:** a second, higher `SessionManager` instance whose "peers" are mains; the orchestrator
|
||||
becomes the top MCP client; context-slice selection (new) layered on CB-303's `context_cap` +
|
||||
@@ -315,7 +337,7 @@ flowchart LR
|
||||
cb402["CB-401/402<br/>Peer Launcher SPI + composite<br/>(DONE / in-flight)"]
|
||||
A["A · SandboxLauncher<br/>(placement-neutral, independent)"]
|
||||
cb308["CB-308 substrate<br/>per-agent channels + global id<br/>+ federated roster"]
|
||||
B["B · main-agent pair<br/>(multi-slot PrimaryRegistry)"]
|
||||
B["B · main-agent pair (SUPERSEDED)<br/>(multi-slot PrimaryRegistry)"]
|
||||
C["C · orchestrator tier<br/>(SessionManager recursed up)"]
|
||||
cb402 --> A
|
||||
cb402 --> cb308
|
||||
@@ -333,7 +355,7 @@ flowchart LR
|
||||
2. **A · SandboxLauncher** — independent; a second proof of the SPI (placement-neutral). Ships anytime.
|
||||
3. **CB-308 substrate** — per-agent channels + global id + federated roster (the multi-host work,
|
||||
promoted from host-to-host to tier-to-tier).
|
||||
4. **B · main-agent pair** — multi-slot `PrimaryRegistry` + per-main inbox routing, on the substrate.
|
||||
4. **B · main-agent pair** — **SUPERSEDED** by lead + two advisory architects.
|
||||
5. **C · orchestrator tier** — the capstone; the recursive session manager + context scoping.
|
||||
|
||||
## 9. Open questions (to resolve at ticket-split)
|
||||
@@ -341,8 +363,8 @@ flowchart LR
|
||||
- **Sandbox mechanism:** container (`docker exec`) vs devcontainer — how the role→image mapping is
|
||||
expressed on the profile. *(Topology **resolved** in §11: distributed = gateway-per-host × local
|
||||
sandboxes; the remaining choice is only the local launch mechanism, not the shape.)*
|
||||
- **Pair semantics:** are the two mains fully symmetric peers, or is one a co-primary that may also
|
||||
delegate? Affects how `PrimaryRegistry` and the "orchestration tools" identity relax.
|
||||
- **Pair semantics:** **SUPERSEDED.** The two-main question is replaced by the architect role's
|
||||
least-privilege boundary: advisory architects can send/reply/ask/read but cannot spawn/stop/drain.
|
||||
- **Orchestrator drivenness:** the mains become programmatically spawned/resumed — does the human
|
||||
still ever type directly into a main, or only into the orchestrator? (The re-root caveat, Fig 7.)
|
||||
- **Context-slice selection:** who decides the slice — orchestrator heuristics, explicit tool args,
|
||||
|
||||
+1
-1
Submodule wiki updated: 8c63db5da6...e5424f4665
Reference in New Issue
Block a user