@mu-crew/mu 3.2.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +203 -599
- package/README.md +76 -327
- package/dist/cli.js.map +1 -1
- package/dist/extension/mu-pi.js +178 -45
- package/dist/extension/mu-pi.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/docs/ARCHITECTURE.md +175 -1072
- package/docs/ROADMAP.md +88 -169
- package/docs/VISION.md +205 -415
- package/docs/VOCABULARY.md +202 -466
- package/docs/architecture/control-socket.md +119 -0
- package/docs/architecture/dag.md +113 -0
- package/docs/architecture/mux.md +163 -0
- package/docs/architecture/ops-log.md +140 -0
- package/docs/architecture/sdk.md +185 -0
- package/docs/architecture/sync.md +129 -0
- package/docs/architecture/tui.md +146 -0
- package/docs/guide/README.md +25 -0
- package/docs/guide/backends.md +61 -0
- package/docs/guide/cleanup.md +74 -0
- package/docs/guide/delegate.md +87 -0
- package/docs/guide/dispatch.md +137 -0
- package/docs/guide/getting-started.md +177 -0
- package/docs/guide/recovery.md +133 -0
- package/docs/guide/remote.md +74 -0
- package/docs/guide/sql.md +76 -0
- package/docs/guide/stop-a-worker.md +64 -0
- package/docs/guide/sync.md +78 -0
- package/docs/guide/tui.md +98 -0
- package/docs/guide/upgrade.md +76 -0
- package/docs/history/CHANGELOG-pre-3.md +4866 -0
- package/docs/reference/env.md +87 -0
- package/docs/reference/naming.md +82 -0
- package/docs/specs/2026-09-28-agent-state-from-murmur.md +1 -1
- package/docs/specs/2026-09-29-task-substates.md +1 -1
- package/package.json +1 -1
- package/skills/mu/REMOTE_WORKERS.md +179 -504
- package/skills/mu/SKILL.md +116 -185
- package/docs/HANDOVER.md +0 -486
- package/docs/USAGE_GUIDE.md +0 -2738
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Control socket
|
|
2
|
+
|
|
3
|
+
mu drives a pi agent through a unix socket served by mu's own pi
|
|
4
|
+
extension inside the agent's interactive pi. The pane stays pi's TUI,
|
|
5
|
+
so a human attached to it shares the same session. There is no
|
|
6
|
+
`pi --mode rpc`, no screen scraping and no daemon: the server lives and
|
|
7
|
+
dies with the pi process. Overview: [ARCHITECTURE.md](../ARCHITECTURE.md).
|
|
8
|
+
|
|
9
|
+
## Path derivation
|
|
10
|
+
|
|
11
|
+
`ctlSocketPath(ws, agent)` (`src/ctl/path.ts`) is
|
|
12
|
+
`<state dir>/sock/<ws>/<agent>.sock`. The state dir is
|
|
13
|
+
`dirname($MU_DB_PATH)` or the default. If the path exceeds 103 bytes
|
|
14
|
+
(the macOS `sun_path` limit), it becomes
|
|
15
|
+
`<state dir>/sock/h/<sha1(ws/agent)[:16]>.sock`.
|
|
16
|
+
|
|
17
|
+
Nothing is stored. Spawn creates the directory (mode 0700), removes any
|
|
18
|
+
stale socket file at the path, and injects the path as `MU_CTL_SOCK`
|
|
19
|
+
into every pane. Every later verb derives the path again.
|
|
20
|
+
|
|
21
|
+
## Protocol v1
|
|
22
|
+
|
|
23
|
+
`src/ctl/protocol.ts` imports nothing from mu, so the extension loads
|
|
24
|
+
it standalone. Framing is JSON lines, one request per connection. Every
|
|
25
|
+
reply carries `v: 1`; the client (`src/ctl/client.ts`) throws
|
|
26
|
+
`CtlVersionError` on anything else.
|
|
27
|
+
|
|
28
|
+
| op | does |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `hello` | identity plus `ops` (served ops) and `extVersion` (the mu version the extension was built from) |
|
|
31
|
+
| `status` | `state` (`busy` / `idle` / `needs_input`), `since`, `runs`, `pending` |
|
|
32
|
+
| `send` | `pi.sendUserMessage`; when busy, `mode` is `steer` or `followUp` (default) |
|
|
33
|
+
| `wait` | resolves on pi's `agent_settled` after `afterRuns`, with the run's `lastText` |
|
|
34
|
+
| `abort` | `ctx.abort()` (pi's Esc) |
|
|
35
|
+
| `fresh` | new session plus prompt as one operation, through the internal `/mu-fresh` command; replies once the new run starts; refused with `busy` unless `force` |
|
|
36
|
+
|
|
37
|
+
These back `mu agent send` (`--fresh`, `--steer`), `mu agent wait`,
|
|
38
|
+
`mu agent abort`, the agent state reading, and `mu_delegate`.
|
|
39
|
+
|
|
40
|
+
## Version skew
|
|
41
|
+
|
|
42
|
+
An op the extension does not serve returns `unknown op: <op>` with its
|
|
43
|
+
`ops` list. mu raises `AgentExtensionOutdatedError` (exit 4), whose
|
|
44
|
+
next steps are `/reload` through the mux or a respawn. `mu doctor`
|
|
45
|
+
probes every pi agent's socket (the `ctl` row) and flags an
|
|
46
|
+
`extVersion` older than the installed mu or a missing op. Its extension
|
|
47
|
+
row reports whether `mu link pi` installed the extension.
|
|
48
|
+
|
|
49
|
+
## Extension lifecycle
|
|
50
|
+
|
|
51
|
+
`extension/mu-pi.ts` is inert unless `MU_CTL_SOCK` is set.
|
|
52
|
+
|
|
53
|
+
- pi re-creates extension runtimes per session. The server, the run
|
|
54
|
+
counters and an in-flight `fresh` therefore live in a process-global
|
|
55
|
+
map (`Symbol.for("mu.pi.ctl")`).
|
|
56
|
+
- Session replacement (`new`, `resume`, `fork`, `reload`) keeps the
|
|
57
|
+
socket and its connections. Quit closes it.
|
|
58
|
+
|
|
59
|
+
**Binding never steals a live socket.**
|
|
60
|
+
|
|
61
|
+
1. If the path already answers, another pi serves it (a nested `pi -p`
|
|
62
|
+
inherited `MU_CTL_SOCK`). The extension leaves it alone and says so
|
|
63
|
+
on stderr.
|
|
64
|
+
2. Otherwise it listens on a private name in the same directory, sets
|
|
65
|
+
mode 0600, and hard-links that name onto the public path. libuv
|
|
66
|
+
unlinks the listened-on name at close, so the public path must never
|
|
67
|
+
be that name. `link()` fails on an existing path, so a racing pi
|
|
68
|
+
keeps its socket.
|
|
69
|
+
3. It records the inode of the public path.
|
|
70
|
+
|
|
71
|
+
On quit it unlinks the public path only if the inode is still its own,
|
|
72
|
+
because another pi may own the path by then. On `session_start`, a
|
|
73
|
+
server whose file was deleted or replaced closes and rebinds. That is
|
|
74
|
+
how `/reload` recovers a lost socket file.
|
|
75
|
+
|
|
76
|
+
## No silent fallback
|
|
77
|
+
|
|
78
|
+
`src/agents/transport.ts` routes sends. `expectsCtl` is true when the
|
|
79
|
+
cli key resolves to pi (`pi` or `pi-meta` as argv0), or when a socket
|
|
80
|
+
file exists at the agent's derived path. The file case covers
|
|
81
|
+
`--cli helper --command "pi-meta ..."`, whose command is not stored.
|
|
82
|
+
|
|
83
|
+
A pi agent whose socket does not answer raises
|
|
84
|
+
`AgentCtlUnreachableError` for send and abort, and reads as `unknown`
|
|
85
|
+
with reason `ctl missing` or `ctl refused` for state. mu never pastes
|
|
86
|
+
into it or asks murmur instead.
|
|
87
|
+
|
|
88
|
+
The mux paste path (bracketed paste, `MU_SEND_DELAY_MS`,
|
|
89
|
+
`MU_SEND_READINESS_MS`) serves only:
|
|
90
|
+
|
|
91
|
+
- non-pi CLIs;
|
|
92
|
+
- adopted panes without ctl;
|
|
93
|
+
- slash commands, because `sendUserMessage` does not run them;
|
|
94
|
+
- an explicit `--via mux`.
|
|
95
|
+
|
|
96
|
+
## Remote agents
|
|
97
|
+
|
|
98
|
+
The user's `ssh` command carries `-L <local derived path>:<remote path>`
|
|
99
|
+
and puts `MU_CTL_SOCK=<remote path>` in the remote env.
|
|
100
|
+
`mu agent remote-env` prints both and runs nothing
|
|
101
|
+
(`src/cli/agents-remote.ts`). mu always connects to the local path, so
|
|
102
|
+
local and remote agents share one transport, and state, send, wait and
|
|
103
|
+
abort are exact without murmur.
|
|
104
|
+
|
|
105
|
+
ssh does not create the local directory, which is why spawn makes it
|
|
106
|
+
first. When ssh dies it leaves the local socket file behind. That file
|
|
107
|
+
probes `refused`, and `mu agent close` or the reaper deletes it.
|
|
108
|
+
|
|
109
|
+
## Parent side: `mu_delegate`
|
|
110
|
+
|
|
111
|
+
`extension/delegate.ts` registers the `mu_delegate` and
|
|
112
|
+
`mu_delegate_cancel` tools in the operator's own pi. They are hidden
|
|
113
|
+
when `MU_MANAGED_AGENT` is set (inside a mu-spawned pane) or
|
|
114
|
+
`MU_DELEGATE=0`. The tools shell out to the `mu` CLI (spawn, send,
|
|
115
|
+
`wait --json`, read, abort, close) and only format the CLI's
|
|
116
|
+
`outcome` into a follow-up message. `delegateOutcome`
|
|
117
|
+
(`src/agents/delegate.ts`) maps one wait result to `done`, `empty`,
|
|
118
|
+
`died`, `timeout` or `pending`. The extension imports nothing from pi;
|
|
119
|
+
it types pi's API structurally.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# The task DAG
|
|
2
|
+
|
|
3
|
+
mu coordinates work through a directed acyclic graph of tasks. Without
|
|
4
|
+
it mu is only an agent runner. Overview:
|
|
5
|
+
[ARCHITECTURE.md](../ARCHITECTURE.md). Terms:
|
|
6
|
+
[VOCABULARY.md](../VOCABULARY.md).
|
|
7
|
+
|
|
8
|
+
## Model
|
|
9
|
+
|
|
10
|
+
- **Tasks** are nodes with mandatory `impact` (1-100) and
|
|
11
|
+
`effort_days`. `ROI = impact / effort` drives prioritization.
|
|
12
|
+
- **One edge type: `blocks`.** `A → B` means A must close before B can
|
|
13
|
+
start. More edge types would make "can this start?" ambiguous.
|
|
14
|
+
- **Status lifecycle:** `OPEN → IN_PROGRESS → CLOSED`. Status alone
|
|
15
|
+
decides edge satisfaction: any `CLOSED` task satisfies its blockers.
|
|
16
|
+
- **Substate** qualifies status and never touches edges:
|
|
17
|
+
`OPEN/todo|parked`, `IN_PROGRESS/active`,
|
|
18
|
+
`CLOSED/done|rejected|wontfix|duplicate|superseded`. The rule is
|
|
19
|
+
"store intent, derive graph facts": ready and blocked stay derived
|
|
20
|
+
from edges. `OPEN/parked` is excluded from `ready` but stays in
|
|
21
|
+
`goals`.
|
|
22
|
+
- **Notes** are append-only per task. They survive LLM context loss
|
|
23
|
+
and agent restarts, which fixes context loss at the task level
|
|
24
|
+
rather than the agent level.
|
|
25
|
+
|
|
26
|
+
## Substate integrity
|
|
27
|
+
|
|
28
|
+
The legal pairs live in the `task_substates` lookup table, seeded from
|
|
29
|
+
code on every open (machine-local, never synced). `tasks` has a
|
|
30
|
+
composite FK `(status, substate) → task_substates`,
|
|
31
|
+
`DEFERRABLE INITIALLY DEFERRED`. That FK is the only guard on the pair.
|
|
32
|
+
|
|
33
|
+
SQLite checks it at COMMIT, so apply's one-field-at-a-time UPDATEs
|
|
34
|
+
pass as long as the pair is legal when the transaction ends. That is
|
|
35
|
+
why `applyOp` wraps each task put in `db.transaction`. In autocommit
|
|
36
|
+
the first per-field UPDATE would fail the FK.
|
|
37
|
+
|
|
38
|
+
Every lifecycle write sets `status` and `substate` in one UPDATE, so
|
|
39
|
+
one op carries both under one HLC. A pair can still split across ops:
|
|
40
|
+
a status-only op from a v10 peer, an unknown substate from a newer
|
|
41
|
+
peer, or a concurrent close and park on two machines. Before commit,
|
|
42
|
+
`repairTaskPair` (`src/apply.ts`) sets the substate to the one implied
|
|
43
|
+
by the newest op in the log that writes a substate, resolved against
|
|
44
|
+
the row's current status. If that pair is illegal, it falls back to
|
|
45
|
+
the status default.
|
|
46
|
+
|
|
47
|
+
Substate is therefore not per-field LWW. Reading the log instead of
|
|
48
|
+
the row makes the result a pure function of the op set plus the row's
|
|
49
|
+
status, so every peer converges whatever the arrival order. For the
|
|
50
|
+
same reason the repair records no op: each peer computes the identical
|
|
51
|
+
repair from the identical log.
|
|
52
|
+
|
|
53
|
+
## Built-in views
|
|
54
|
+
|
|
55
|
+
| View | Returns |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `ready` | `OPEN/todo` tasks with no unresolved blockers: work that can start now |
|
|
58
|
+
| `blocked` | OPEN tasks waiting on something |
|
|
59
|
+
| `goals` | tasks with no dependents: graph endpoints |
|
|
60
|
+
|
|
61
|
+
Agents and humans query these views directly through `mu sql`. There
|
|
62
|
+
is no separate query layer.
|
|
63
|
+
|
|
64
|
+
## Parallel tracks
|
|
65
|
+
|
|
66
|
+
The Tracks section of `mu state` and bare `mu` runs union-find
|
|
67
|
+
(`src/tracks.ts`) to find independent subtrees that different agents
|
|
68
|
+
can work in parallel.
|
|
69
|
+
|
|
70
|
+
Diamonds merge. If two roots share a prerequisite, they collapse into
|
|
71
|
+
one track, so two agents never collide on the shared dependency:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Independent (2 tracks): Diamond (1 merged track):
|
|
75
|
+
|
|
76
|
+
goal_a goal_b goal_a goal_b ← Spawn 2 agents
|
|
77
|
+
| | \ /
|
|
78
|
+
task_a task_b shared ← Spawn 1 (would
|
|
79
|
+
| | | collide otherwise)
|
|
80
|
+
leaf_a leaf_b leaf
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The graph algorithm decides, not the LLM.
|
|
84
|
+
|
|
85
|
+
## Claim protocol
|
|
86
|
+
|
|
87
|
+
`mu task claim <task>` resolves the claimer, then atomically sets
|
|
88
|
+
`tasks.owner`, flips `status` to `IN_PROGRESS`, and records a
|
|
89
|
+
`task.claim` op carrying the resolved `actor`. The claim is a CAS in
|
|
90
|
+
SQLite, so two agents cannot claim the same task.
|
|
91
|
+
|
|
92
|
+
Identity resolution (`resolveWorkerIdentity`, `src/tasks/claim.ts`) is
|
|
93
|
+
a two-rung ladder:
|
|
94
|
+
|
|
95
|
+
1. **`$MU_AGENT_NAME`**, injected into the pane's environment at spawn.
|
|
96
|
+
It is backend-independent.
|
|
97
|
+
2. **`backend.currentAgentName()`**, the mux fallback. On tmux it reads
|
|
98
|
+
the pane title (`display-message -p '#{pane_title}'`), never the
|
|
99
|
+
window name, because one window can hold several agents. On herdr
|
|
100
|
+
the name is registered with the mux and looked up by pane id.
|
|
101
|
+
|
|
102
|
+
The fallback exists for **adopted** panes (`mu agent adopt`), which
|
|
103
|
+
predate the env injection and carry only a title. The agent does not
|
|
104
|
+
need to know its own name.
|
|
105
|
+
|
|
106
|
+
## Scoped subtree views
|
|
107
|
+
|
|
108
|
+
`mu task tree <id>` and the task queries show the part of the graph
|
|
109
|
+
reachable from a task. Scoping is a `WHERE` clause, which makes
|
|
110
|
+
recursive delegation work: a sub-orchestrator inspects its slice
|
|
111
|
+
without an LLM inferring the scope. `src/dag.ts` (`loadFullDag`,
|
|
112
|
+
`renderForest`, `renderTaskTree`) serves both `mu task tree` and the
|
|
113
|
+
TUI DAG popup.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Mux topology, backends and reconciliation
|
|
2
|
+
|
|
3
|
+
mu runs agents in multiplexer panes, tmux or herdr, behind one
|
|
4
|
+
`MuxBackend` interface. Overview: [ARCHITECTURE.md](../ARCHITECTURE.md).
|
|
5
|
+
|
|
6
|
+
## One mux session per workstream
|
|
7
|
+
|
|
8
|
+
One workstream is one mux session and one `session_id` partition in
|
|
9
|
+
`~/.local/state/mu/mu.db`. Workstreams on one machine are independent
|
|
10
|
+
mux sessions.
|
|
11
|
+
|
|
12
|
+
| mu term | tmux | herdr |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| **mux session** | session | workspace |
|
|
15
|
+
| **window** | window | tab |
|
|
16
|
+
| **pane** | pane (`%15`) | pane (`w1:p1`) |
|
|
17
|
+
|
|
18
|
+
herdr's own "session" is server-level (one socket), which is the wrong
|
|
19
|
+
granularity, so a herdr workspace plays the mux-session role.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
tmux session: mu-auth-refactor (one mu workstream)
|
|
23
|
+
┌───────────────────────────────────────────────────────────┐
|
|
24
|
+
│ Window: Backend Window: Review │
|
|
25
|
+
│ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
|
|
26
|
+
│ │ worker-1 │ │ worker-2 │ │ reviewer-1 │ │
|
|
27
|
+
│ │ (pi) │ │ (pi) │ │ (pi, role=read-only) │ │
|
|
28
|
+
│ └──────────┘ └──────────┘ └────────────────────────┘ │
|
|
29
|
+
│ Window: mu-orchestrator │
|
|
30
|
+
│ ┌────────────────────────────────────────────────────┐ │
|
|
31
|
+
│ │ pi (you, with mu extension loaded) │ │
|
|
32
|
+
│ └────────────────────────────────────────────────────┘ │
|
|
33
|
+
└───────────────────────────────────────────────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- The first `mu agent spawn` creates the session if needed. Name it
|
|
37
|
+
with `mu workstream init <name>` or `MU_SESSION=<name>`.
|
|
38
|
+
- `tmux attach -t mu-<workstream>` shows the whole crew (herdr:
|
|
39
|
+
`herdr workspace focus <id>`). Killing the session kills the
|
|
40
|
+
workstream, with no leaked panes, and the crew survives a closed
|
|
41
|
+
laptop.
|
|
42
|
+
- `mu agent list` is scoped to one workstream; `-w` picks another.
|
|
43
|
+
- `mu doctor` warns about orphan panes, ghost rows, and agents whose
|
|
44
|
+
mux session is gone.
|
|
45
|
+
|
|
46
|
+
**Window vs pane.** Each agent gets its own window, named by `--tab`
|
|
47
|
+
(default: the agent name). Agents sharing a `--tab` value share a
|
|
48
|
+
window. Identity never depends on the window: each pane gets
|
|
49
|
+
`$MU_AGENT_NAME` plus a **pane title** fallback. The title carries
|
|
50
|
+
durable context only (agent name and owned tasks), re-pushed by
|
|
51
|
+
`composeAgentTitle` after each relevant change. mu enables the top
|
|
52
|
+
border and leaves `pane-border-format` to the user's tmux config. The
|
|
53
|
+
canonical tmux protocol is the comment block at the top of
|
|
54
|
+
`src/mux/tmux.ts`.
|
|
55
|
+
|
|
56
|
+
## The `activeMux()` seam
|
|
57
|
+
|
|
58
|
+
Every call site reaches its multiplexer through
|
|
59
|
+
`(await activeMux()).<method>()`. Nothing outside `src/mux/` names a
|
|
60
|
+
backend. `src/mux/detect.ts` picks one: `MU_MUX` → `HERDR_ENV` →
|
|
61
|
+
`$TMUX` → `PATH`. An unknown `MU_MUX` value fails the invocation
|
|
62
|
+
instead of falling back to tmux.
|
|
63
|
+
|
|
64
|
+
Each call site has decided what "no reachable multiplexer" means:
|
|
65
|
+
|
|
66
|
+
| | On `NoMultiplexerError` | Examples |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| **Load-bearing** | propagate; `handle()` maps it to exit 5 | spawn, send, read, kill, adopt, kick, reconcile, session create and destroy |
|
|
69
|
+
| **Best-effort** | `try`/`catch`, degrade | actor identity, pane titles, pane borders, workstream listings, liveness polls, TUI attach |
|
|
70
|
+
|
|
71
|
+
`resolveWorkerIdentity()` in `src/tasks/claim.ts` is the canonical
|
|
72
|
+
best-effort shape. `reconcile()` is load-bearing on purpose: treating
|
|
73
|
+
an unreachable mux as "zero panes" would prune every agent as a ghost
|
|
74
|
+
and reap its in-progress tasks.
|
|
75
|
+
|
|
76
|
+
Three concerns belong to the backend, because each would otherwise
|
|
77
|
+
hardcode a tmux string a herdr user sees:
|
|
78
|
+
|
|
79
|
+
- `attachHint()` and `attachCommands()`: the printed recipe and the
|
|
80
|
+
TUI's executed argv.
|
|
81
|
+
- `healthCheck()`: version and env facts as data; `mu doctor` renders.
|
|
82
|
+
- `paneNotFoundNextSteps()`: borrowed by `PaneNotFoundError` from the
|
|
83
|
+
backend that raised it.
|
|
84
|
+
|
|
85
|
+
`src/tmux.ts` survives as a re-export for tmux-only concerns: the
|
|
86
|
+
`MU_TMUX_SOCKET` test-isolation seam and the shared `sleep` /
|
|
87
|
+
`setSleepForTests` poll seam.
|
|
88
|
+
|
|
89
|
+
## Spawn: create-and-run vs create-then-start
|
|
90
|
+
|
|
91
|
+
tmux creates a pane and runs a command in one call. herdr has no
|
|
92
|
+
create-and-run form: its creation verbs start a plain shell, and
|
|
93
|
+
`agent start` needs an existing pane at its prompt. mu expresses this
|
|
94
|
+
as a capability, not a name check:
|
|
95
|
+
|
|
96
|
+
| | tmux | herdr |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `NewWindowOptions.command` | carries the command | refused if non-empty |
|
|
99
|
+
| `startAgentInPane()` | absent | implemented |
|
|
100
|
+
| after start | mu checks pane life and startup errors, then murmur readiness for non-pi CLIs | the mux returns once the agent is ready |
|
|
101
|
+
|
|
102
|
+
`spawnAgent` branches on `mux.startAgentInPane !== undefined`.
|
|
103
|
+
|
|
104
|
+
- **tmux.** After `MU_SPAWN_LIVENESS_MS` (default 1500, `0` skips) mu
|
|
105
|
+
verifies the pane survived and scans its tail for provider or auth
|
|
106
|
+
startup errors. If murmur is installed, it then polls until murmur
|
|
107
|
+
claims the pane (`MU_SPAWN_READINESS_MS`, default 10s). Without
|
|
108
|
+
murmur that poll is skipped.
|
|
109
|
+
- **herdr.** mu creates a bare pane and calls `startAgentInPane`, which
|
|
110
|
+
subsumes both checks.
|
|
111
|
+
- **pi, either backend.** After the agent starts, mu polls its control
|
|
112
|
+
socket until the extension answers (`MU_SPAWN_CTL_MS`, default 30s;
|
|
113
|
+
`--no-ctl` or `0` skips). A timeout is reported but not rolled back:
|
|
114
|
+
the pane and pi are fine, only control is missing. See
|
|
115
|
+
[control-socket.md](control-socket.md).
|
|
116
|
+
|
|
117
|
+
A creation verb refuses a command it cannot honour. A dropped command
|
|
118
|
+
would leave an empty shell that mu believes hosts an agent. The
|
|
119
|
+
refusal and every failure before the handshake route through
|
|
120
|
+
`rollbackSpawn`, so no agent row survives for an empty pane.
|
|
121
|
+
|
|
122
|
+
Spawns serialize per mux session (`src/agents/spawn-lock.ts`, over
|
|
123
|
+
`src/file-lock.ts`) only around the topology check-then-act and the
|
|
124
|
+
row insert. Agent start and the readiness waits sit outside the lock,
|
|
125
|
+
so a parallel fan-out still runs in parallel.
|
|
126
|
+
|
|
127
|
+
## Reconciliation
|
|
128
|
+
|
|
129
|
+
`mu agent list` reconciles the registry against the mux before
|
|
130
|
+
returning. `src/reconcile.ts` is the only implementation, and
|
|
131
|
+
`mu doctor` calls the same routine.
|
|
132
|
+
|
|
133
|
+
1. **Prune ghosts.** Delete each `agents` row whose `pane_id` no longer
|
|
134
|
+
exists.
|
|
135
|
+
2. **Surface orphans.** List panes in the workstream's session that
|
|
136
|
+
have no row but whose title looks like an agent name. mu never
|
|
137
|
+
adopts them; the user runs `mu agent adopt <pane> [--name X]`.
|
|
138
|
+
|
|
139
|
+
The mux is the source of truth for which panes exist.
|
|
140
|
+
|
|
141
|
+
## Agent state
|
|
142
|
+
|
|
143
|
+
Runtime state is read on demand and never persisted.
|
|
144
|
+
`src/agent-state.ts` picks one source per agent:
|
|
145
|
+
|
|
146
|
+
| Agent | Source |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| pi (`expectsCtl`) | its control socket, probed in parallel for all pi agents |
|
|
149
|
+
| other CLI on herdr | herdr's native `paneStatus()` |
|
|
150
|
+
| other CLI on tmux | murmur: one `list-panes -a` reads `@murmur_pane_state` / `@murmur_pane_since`; agents without a local option fall back to `murmur status --json`, cached 10s |
|
|
151
|
+
|
|
152
|
+
- ctl `idle` maps to `needs_input`. murmur maps `working` → `busy`,
|
|
153
|
+
`blocked` → `needs_permission`, and `idle` / `done` / `crashed` →
|
|
154
|
+
`needs_input`. herdr supplies mu's states directly. See murmur's
|
|
155
|
+
[stable contract](https://github.com/mu-crew/murmur/blob/main/ARCHITECTURE.md#contract).
|
|
156
|
+
- An absent reading is `unknown` with a reason: `ctl missing`,
|
|
157
|
+
`ctl refused`, `murmur not installed`, `murmur pi extension not
|
|
158
|
+
linked`, `murmur has no row`, `remote snapshot stale`, `herdr reports
|
|
159
|
+
no state`, or `pane gone`.
|
|
160
|
+
- `src/mux/input-timing.ts` reads pane text only to decide when tmux
|
|
161
|
+
input can be submitted on the paste path. It does not report state.
|
|
162
|
+
- `agents.status` is deprecated. Inserts write `spawning` to satisfy the
|
|
163
|
+
schema; nothing reads it as runtime state.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# The ops log
|
|
2
|
+
|
|
3
|
+
mu keeps one append-only table, `ops`. History, undo, sync, rebuild and
|
|
4
|
+
drift detection are all queries or replays over it. Overview:
|
|
5
|
+
[ARCHITECTURE.md](../ARCHITECTURE.md).
|
|
6
|
+
|
|
7
|
+
## Capture is a trigger
|
|
8
|
+
|
|
9
|
+
Every INSERT, UPDATE and DELETE on a portable table (`workstreams`,
|
|
10
|
+
`tasks`, `task_edges`, `task_notes`) is recorded as an **op** by a
|
|
11
|
+
SQLite trigger, in the same transaction as the mutation. No SDK
|
|
12
|
+
function decides to record history, so no call site can forget to, and
|
|
13
|
+
the log cannot drift from the data, even on power loss.
|
|
14
|
+
|
|
15
|
+
The tables are the materialized view; the log is the record. Reads hit
|
|
16
|
+
the tables as ordinary indexed SQL. Nothing replays the log to answer
|
|
17
|
+
a query. The log is what the tables are derived from and can be
|
|
18
|
+
rebuilt from.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
mu task close t3
|
|
22
|
+
│
|
|
23
|
+
▼
|
|
24
|
+
UPDATE tasks SET status=... ──┬──► tables (what reads see)
|
|
25
|
+
└──► ops (what everything else reads)
|
|
26
|
+
one transaction
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Feature | Over the log |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| history | `mu log` is a typed reader over `ops` (`src/logs.ts`) |
|
|
32
|
+
| undo | `mu undo <group>` emits the inverse ops for one group |
|
|
33
|
+
| sync | each machine appends its own ops to a JSONL segment and applies each peer's from a watermark ([sync.md](sync.md)) |
|
|
34
|
+
| rebuild | `mu rebuild <file>` replays the whole log into a new DB |
|
|
35
|
+
| drift | `mu doctor --deep` rebuilds into a temp DB and diffs it against the live tables |
|
|
36
|
+
|
|
37
|
+
**Ops are semantic partial updates.** An UPDATE op carries only the
|
|
38
|
+
columns that changed. That makes per-field merge free: two machines
|
|
39
|
+
editing different fields of one task both keep their edit. A full-row
|
|
40
|
+
payload would silently regress this to row-level last-writer-wins.
|
|
41
|
+
|
|
42
|
+
**The cost:** one capture bug breaks history, undo and sync at once,
|
|
43
|
+
silently. Hence the two drift tiers below.
|
|
44
|
+
|
|
45
|
+
**Ordering** across machines comes from the **HLC** (`src/hlc.ts`) on
|
|
46
|
+
every op: a hybrid logical clock serialized as sortable TEXT
|
|
47
|
+
`<wall_ms:15>.<counter:6>.<machine_id>`, so bytewise `ORDER BY hlc` is
|
|
48
|
+
causal order. Clock state lives in `machine_identity`.
|
|
49
|
+
|
|
50
|
+
**Machine-local tables** (`agents`, `vcs_workspaces`) have no capture
|
|
51
|
+
triggers. Their contents are pane ids and absolute paths, which mean
|
|
52
|
+
nothing on another machine. Their changes are recorded as log-only
|
|
53
|
+
events through `emitEvent`. `appendLog` writes operator prose
|
|
54
|
+
(`mu log "text"`).
|
|
55
|
+
|
|
56
|
+
## Trigger mechanics
|
|
57
|
+
|
|
58
|
+
- **Capture triggers are TEMP triggers**, reinstalled per connection
|
|
59
|
+
by `src/capture.ts`. SQLite refuses a main-schema trigger that
|
|
60
|
+
references the temp `_op_ctx` table. Two consequences follow:
|
|
61
|
+
- DELETE keys are captured inline, with the parent stashing its
|
|
62
|
+
natural key in `_op_dying` first. FK CASCADE fires child triggers
|
|
63
|
+
after the parent is gone.
|
|
64
|
+
- The HLC is minted in SQL, because a trigger cannot call into JS.
|
|
65
|
+
- **Op context** (`src/op-context.ts`): `withOpContext(db, {intent,
|
|
66
|
+
actor, group}, fn)` labels every op in a scope and restores in a
|
|
67
|
+
`finally`. Nested scopes inherit the group, which puts a cascade
|
|
68
|
+
under one `mu undo`. `withCaptureSuppressed` is the echo guard used
|
|
69
|
+
by the apply path.
|
|
70
|
+
- An importer must synthesize **ops, not rows**. A direct INSERT is
|
|
71
|
+
invisible to sync and reported as drift.
|
|
72
|
+
|
|
73
|
+
## Apply and merge
|
|
74
|
+
|
|
75
|
+
`src/apply.ts` is capture's counterpart: given one op, local or from a
|
|
76
|
+
peer, it makes the tables reflect it.
|
|
77
|
+
|
|
78
|
+
- **Merge is per entity.** Notes are grow-only sets. Tasks and
|
|
79
|
+
workstreams merge per field by HLC. Edges are an LWW-element-set.
|
|
80
|
+
- A **tombstone** is an ordinary op with an HLC, so out-of-order
|
|
81
|
+
arrival is a comparison and resurrection falls out.
|
|
82
|
+
- Never use `json_patch`: RFC 7396 reads a null member as
|
|
83
|
+
delete-the-key.
|
|
84
|
+
- Task `status` and `substate` are repaired as a pair, not merged per
|
|
85
|
+
field. See [dag.md](dag.md#substate-integrity).
|
|
86
|
+
|
|
87
|
+
## Undo
|
|
88
|
+
|
|
89
|
+
`src/undo.ts` derives the inverse ops for one `group_id` from log
|
|
90
|
+
provenance. It refuses a superseded group (exit 4; `--force`
|
|
91
|
+
overrides). Bare `mu undo` lists undoable groups (`-n` widens), a group
|
|
92
|
+
prefix previews, and `--yes` applies.
|
|
93
|
+
|
|
94
|
+
- **Undo and restore write through the tables**, so capture records
|
|
95
|
+
them like any other write.
|
|
96
|
+
- `restore` records and applies under the **same** HLC. `applyOp`
|
|
97
|
+
excludes an op's own HLC from provenance. A fresher HLC would make
|
|
98
|
+
the row outrank the op and lose every field to an insert default.
|
|
99
|
+
- **Note tombstones are self-describing.** Every other `del` carries
|
|
100
|
+
`'{}'`, because its key plus earlier puts describe the row. A note's
|
|
101
|
+
key embeds its rowid (`<ws>/<task>#<id>`), and a rebuild or
|
|
102
|
+
reprojection reassigns rowids. The `task_notes` delete trigger
|
|
103
|
+
therefore records `OLD.*`, and `planUndo` falls back to the
|
|
104
|
+
tombstone payload when folding the puts finds nothing. For the same
|
|
105
|
+
reason `src/drift.ts` matches notes on the task-key prefix.
|
|
106
|
+
|
|
107
|
+
## Rebuild
|
|
108
|
+
|
|
109
|
+
`src/rebuild.ts` (`rebuildInto`) replays the whole log into a new DB
|
|
110
|
+
file through `applyOp`. The verb prints counts, supports `--json`,
|
|
111
|
+
suggests an `mv` swap, and warns that `agents` and `vcs_workspaces`
|
|
112
|
+
cannot be reconstructed.
|
|
113
|
+
|
|
114
|
+
Rebuild is not ingest:
|
|
115
|
+
|
|
116
|
+
- Ingest filters to `SYNCED_ENTITIES`. Rebuild replays everything, so
|
|
117
|
+
log-only entities are copied verbatim, or `mu log` comes back empty.
|
|
118
|
+
- `src/legacy-ops.ts` classifies historical log-only intents whose
|
|
119
|
+
entity looks projectable. Rebuild copies them without projection;
|
|
120
|
+
flush never emits them.
|
|
121
|
+
- `machine_identity` carries across whole: id, hostname and HLC clock.
|
|
122
|
+
Otherwise the rebuilt DB is a different peer minting HLCs below
|
|
123
|
+
every replayed op.
|
|
124
|
+
|
|
125
|
+
## Drift
|
|
126
|
+
|
|
127
|
+
`src/drift.ts` has two tiers:
|
|
128
|
+
|
|
129
|
+
| Tier | Runs | Checks |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| cheap (`checkCheapDriftInvariant`) | default `mu doctor` | every live row has at least one op naming its key |
|
|
132
|
+
| deep (`checkDrift`) | `mu doctor --deep` | rebuilds into a temp DB and diffs field by field |
|
|
133
|
+
|
|
134
|
+
The deep diff matches rows by natural key and excludes `owner_id`,
|
|
135
|
+
because a rebuild always has NULL owners. `driftRemediation` prints the
|
|
136
|
+
fix.
|
|
137
|
+
|
|
138
|
+
For disaster recovery, `mu db backup <file>` writes one scp-able file
|
|
139
|
+
through `VACUUM INTO`. It never overwrites; it is the copy
|
|
140
|
+
`SchemaTooOldError` tells you to take.
|