Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1900bc9
fix(pi): say when a live push body was truncated (#132)
lobo235 Nov 5, 2026
e335d41
fix(pi, prime-agent): ack an inbox pull only once it has been shown (…
lobo235 Nov 5, 2026
5f4189d
fix(opencode): stop acking unshown messages after a live push, flag t…
lobo235 Nov 5, 2026
585be48
fix(pi): don't assume a prompt in before_agent_start (#147)
lobo235 Nov 5, 2026
c706c65
feat: add --help and --version to marshal-daemon, marshal-shim and ma…
lobo235 Nov 5, 2026
611e47a
chore(release): v0.55.0
github-actions[bot] Nov 5, 2026
46c1886
fix(pi): don't drain the inbox after a live push (#134)
lobo235 Nov 5, 2026
e535b61
fix(entities): make read-state sticky across a session reload (#131)
lobo235 Nov 5, 2026
3ed3ab3
chore(release): v0.55.1
github-actions[bot] Nov 5, 2026
8519544
docs(readme): say which dependencies are AGPL (#165)
Castor-o7 Nov 5, 2026
7cfcad9
fix(shim): keep a Claude push unread until the shim has written it (#…
lobo235 Nov 5, 2026
f5e7efa
chore(release): v0.55.2
github-actions[bot] Nov 5, 2026
203f6ef
feat(shim): pull the inbox into Claude Code turns with plugin hooks (…
lobo235 Nov 5, 2026
6720ac7
chore(release): v0.56.0
github-actions[bot] Nov 5, 2026
f47c932
feat: deliver to and wake Claude Code without the channels flag (fixe…
Castor-o7 Nov 5, 2026
8362227
chore(release): v0.57.0
github-actions[bot] Nov 5, 2026
0862bf0
feat: preserve CI and embedding metadata follow-ups (#166)
lucid-max Nov 5, 2026
9b00c6f
chore(release): v0.58.0
github-actions[bot] Nov 5, 2026
18d5195
Merge upstream Marshal v0.58.0 (9b00c6f) into Muster (MUS-97, Step 0)
Castor-o7 Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
self-hosted-runner:
labels:
- ram-ge-16g
- ram-ge-24g
- disk-ge-100g
- site-lucid-lab
31 changes: 29 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
- run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config libssl-dev zsh netcat-openbsd
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
# Each published crate ships its own copy of the licences; they must
Expand Down Expand Up @@ -152,9 +153,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Report runner resources
if: always()
continue-on-error: true
timeout-minutes: 1
run: python3 scripts/ci/report_resources.py

- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
toolchain: stable
targets: x86_64-pc-windows-gnu
- uses: Swatinem/rust-cache@v2
# Only the x86_64 C cross-compiler: the build scripts compile C for the
Expand Down Expand Up @@ -212,3 +220,22 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo install --locked cargo-deny --version 0.20.2
- run: cargo deny check

# Native Windows (from upstream Marshal v0.58.0): cross-compiling catches a
# cfg(windows) build break, but not the launcher and bridge behaviour. This
# runs the shim's tests on a real Windows runner, including the Codex wake
# canary, after an offline check of the runner-isolation recipe.
windows-native-wake:
runs-on: windows-2025
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
save-if: false
- name: Test Windows isolation recipe offline
shell: pwsh
run: ./scripts/ci/test_windows_isolation.ps1
- name: Run native Windows Codex wake canary
run: cargo test -p muster-shim -- --nocapture
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,11 @@ context. Batch related details and reserve direct delivery for action, a
blocker, or a needed reply. Use an unmentioned room broadcast for FYI/progress.
Automatic body previews are bounded (2,000 characters per message and an
8,000-character hook-batch budget); the complete durable message remains
available through `muster://messages`. Codex wakes coalesce for 30 seconds so
available through `muster://messages`. A hook's inbox block stays under
Claude Code's 10,000-character context limit: messages that don't fit stay
unread for the next turn, counted in `remaining`. Message text in the block is
escaped (`<`, `>`, `&`, continuation lines indented), so a message
can't close the block or pose as a sender line. Codex wakes coalesce for 30 seconds so
a burst can join the active turn.

## Architecture
Expand Down Expand Up @@ -582,7 +586,13 @@ on a clean machine, and Windows beyond a cross-compile.

MIT OR Apache-2.0, at your option (`LICENSE-MIT`, `LICENSE-APACHE`). Muster is a fork of [Marshal](https://github.com/ignition-is-go/marshal), under the same licence.

That covers Muster's own source code. Muster depends on myko, myko-macros and autosocket
(and myko-server for `muster-daemon`), which declare the AGPL-3.0-or-later licence. Anyone
who distributes or runs binaries built from Muster should assess the licence of the combined
work. This isn't legal advice.
That covers Muster's own source code. These dependencies declare the AGPL-3.0-or-later
licence, at the versions pinned here:

- myko, used by every crate, and myko-macros, used by `muster-entities`
- autosocket, through myko
- myko-server, used by `muster-daemon`
- myko-leptos, used by `muster-ui`

Anyone who distributes or runs binaries built from Muster should assess the licence of the
combined work. This isn't legal advice.
1 change: 1 addition & 0 deletions crates/daemon/src/auto_rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ mod tests {
Session {
id: SessionId(Arc::from(id)),
client_id: None,
agent_id: None,
pid: 0,
cwd: String::new(),
git_branch: None,
Expand Down
159 changes: 118 additions & 41 deletions crates/daemon/src/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ use std::{

use chrono::Utc;
use hyphae::{Gettable, Materialize};
use muster_entities::{AutoSource, Message, Room, RoomKind, RoomMember, Session, WakeWatcher};
use muster_entities::{
AutoSource, Message, MessageRead, Room, RoomKind, RoomMember, Session, WakeWatcher,
};
use myko::{core::item::Eventable, server::MykoServerContext, utils::downcast_item};

/// How long a WS-shim session must be without a live client before DEL. Sized
Expand All @@ -52,8 +54,8 @@ pub const STALE_AFTER: Duration = Duration::from_secs(60);
/// Safe by construction: every prompt-submit hook idempotently re-registers
/// the session (see `hooks::register_hook_session`), so a live TUI idle past
/// the backstop gets its row repaired on its next prompt. The cost of a
/// false-positive reap is bounded — unread direct messages older than the
/// backstop cascade away with the row — while the cost of no backstop is
/// false-positive reap is small — the row comes back on the next hook, and
/// its direct messages and read marks survive the DEL — while the cost of no backstop is
/// unbounded store growth. Sized generously above any plausible think-time
/// gap of a live session.
pub const PULL_STALE_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
Expand All @@ -67,10 +69,12 @@ pub const PULL_STALE_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
pub const KEPT_STALE_AFTER: Duration = Duration::from_secs(10 * 60);

/// Messages older than this are pruned by `sweep_messages`, regardless of
/// recipient. It's what bounds direct mail to a reaped session, which no longer
/// cascades on its recipient and so survives a restart (MUS-46), and broadcasts
/// to the never-DEL'd `everyone`/`op:`/`project:` rooms (review R6/D). A pruned
/// message's `MessageRead` rows go at the next boot.
/// recipient. A direct message outlives a DEL'd recipient (it comes back
/// under the same id, and mail survives a restart: MUS-46), so this is the only
/// thing that removes it. A broadcast goes with its room, but broadcasts to the
/// never-DEL'd `everyone`/`op:`/`project:` rooms would otherwise accumulate
/// forever (review R6/D). The prune doesn't cascade, so the same sweep drops
/// the pruned messages' `MessageRead` rows (`sweep_orphan_reads`).
pub const MESSAGE_TTL: Duration = Duration::from_secs(14 * 24 * 60 * 60);

/// Run the message-retention sweep every N session-sweeper ticks. The scan is
Expand Down Expand Up @@ -206,12 +210,17 @@ fn sweep_wake_watchers(ctx: &MykoServerContext) {
}
}

/// Prune messages older than `MESSAGE_TTL`. Bounds direct mail to reaped
/// sessions (MUS-46) and broadcasts to rooms that are never DEL'd, which would
/// otherwise grow the store without limit. Read-state goes at the next boot:
/// `del_by_id` doesn't cascade, so `MessageRead` rows wait for orphan cleanup
/// on `belongs_to(Message)` (MUS-31).
/// Prune messages older than `MESSAGE_TTL`. Direct messages don't cascade
/// with their recipient and the auto-rooms are never DEL'd, so this is what
/// keeps the store from growing without limit. `del_by_id` skips
/// relationship cascades, so the pruned messages' `MessageRead` rows are
/// swept separately (`sweep_orphan_reads`).
fn sweep_messages(ctx: &MykoServerContext) {
prune_old_messages(ctx);
sweep_orphan_reads(ctx);
}

fn prune_old_messages(ctx: &MykoServerContext) {
let Some(store) = ctx.registry.get(Message::ENTITY_NAME_STATIC) else {
return;
};
Expand Down Expand Up @@ -239,6 +248,52 @@ fn sweep_messages(ctx: &MykoServerContext) {
}
}

/// DEL every `MessageRead` whose message no longer exists: the ones a prune
/// left behind, and any orphaned another way. Read-state no longer cascades
/// on a session DEL, so this is what bounds it.
fn sweep_orphan_reads(ctx: &MykoServerContext) {
let Some(read_store) = ctx.registry.get(MessageRead::ENTITY_NAME_STATIC) else {
return;
};
// Reads first, then messages: a read is written after its message, so
// every read in this snapshot has its message in the next one, and a
// send landing mid-sweep never loses its read.
let reads = read_store.entries().materialize().get();
let messages: HashSet<Arc<str>> = ctx
.registry
.get(Message::ENTITY_NAME_STATIC)
.map(|store| {
store
.entries()
.materialize()
.get()
.into_iter()
.map(|(id, _)| id)
.collect()
})
.unwrap_or_default();
let mut to_delete: Vec<Arc<str>> = Vec::new();
for (id, item) in reads {
if let Some(read) = downcast_item::<MessageRead>(&item)
&& !messages.contains(&read.message_id.0)
{
to_delete.push(id);
}
}
if to_delete.is_empty() {
return;
}
log::info!(
"[cleanup] dropping {} read mark(s) whose message is gone",
to_delete.len()
);
for id in to_delete {
if let Err(e) = ctx.del_by_id(MessageRead::ENTITY_NAME_STATIC, &id) {
log::warn!("[cleanup] drop read mark {} failed: {}", id, e);
}
}
}

/// GC pass: DEL auto-rooms that no longer earn their place — any `host:`
/// room (that anchor is retired; see `auto_rooms.rs`) and any auto-room that
/// has dropped to zero members (its anchoring sessions were all reaped, e.g.
Expand Down Expand Up @@ -481,7 +536,9 @@ fn sweep_once(ctx: &MykoServerContext, state: &mut SweepState) {
#[cfg(test)]
mod tests {
use super::*;
use muster_entities::{Message, MessageId, RoomId, RoomMemberId, SessionId};
use muster_entities::{
Message, MessageId, MessageRead, MessageReadId, RoomId, RoomMemberId, SessionId,
};
use myko::{
server::Persister,
wire::{MEvent, MEventType},
Expand All @@ -506,6 +563,7 @@ mod tests {
let session = Session {
id: SessionId(Arc::from(id)),
client_id: None,
agent_id: None,
pid: 0,
cwd: "/repo".into(),
git_branch: None,
Expand Down Expand Up @@ -575,6 +633,7 @@ mod tests {
let session = Session {
id: SessionId(Arc::from(id)),
client_id: Some(myko::entities::client::ClientId(Arc::from(client))),
agent_id: None,
pid: 1,
cwd: "/repo".into(),
git_branch: None,
Expand Down Expand Up @@ -847,6 +906,52 @@ mod tests {
.unwrap_or_default()
}

fn set_read(ctx: &MykoServerContext, message_id: &str, session_id: &str) {
let read = MessageRead {
id: MessageReadId(Arc::from(MessageRead::make_id(message_id, session_id))),
message_id: MessageId(Arc::from(message_id)),
session_id: SessionId(Arc::from(session_id)),
read_at: 1,
};
let ev = MEvent::from_item(&read, MEventType::SET, &Uuid::new_v4().to_string());
ctx.apply_event_batch(vec![ev])
.expect("apply MessageRead SET");
}

fn read_ids(ctx: &MykoServerContext) -> HashSet<String> {
ctx.registry
.get(MessageRead::ENTITY_NAME_STATIC)
.map(|s| {
s.entries()
.materialize()
.get()
.into_iter()
.map(|(id, _)| id.to_string())
.collect()
})
.unwrap_or_default()
}

/// `del_by_id` skips relationship cascades, so a pruned message's reads,
/// and reads whose message is gone some other way, must be swept here.
#[test]
fn sweep_messages_drops_reads_whose_message_is_gone() {
let ctx = setup();
let now = chrono::Utc::now().timestamp_millis();
set_message(&ctx, "old", now - MESSAGE_TTL.as_millis() as i64 - 1);
set_message(&ctx, "recent", now - 1_000);
set_read(&ctx, "old", "recipient");
set_read(&ctx, "recent", "recipient");
set_read(&ctx, "never-existed", "recipient");

sweep_messages(&ctx);

assert_eq!(
read_ids(&ctx),
HashSet::from(["recent::recipient".to_string()])
);
}

#[test]
fn sweep_messages_prunes_old_and_keeps_recent() {
let ctx = setup();
Expand Down Expand Up @@ -1019,34 +1124,6 @@ mod tests {
(server, ctx)
}

fn set_read(ctx: &MykoServerContext, message: &str, session: &str) {
let read = muster_entities::MessageRead {
id: muster_entities::MessageReadId(Arc::from(
muster_entities::MessageRead::make_id(message, session).as_str(),
)),
message_id: MessageId(Arc::from(message)),
session_id: SessionId(Arc::from(session)),
read_at: Utc::now().timestamp_millis(),
};
let ev = MEvent::from_item(&read, MEventType::SET, &Uuid::new_v4().to_string());
ctx.apply_event_batch(vec![ev])
.expect("apply MessageRead SET");
}

fn read_ids(ctx: &MykoServerContext) -> HashSet<String> {
ctx.registry
.get(muster_entities::MessageRead::ENTITY_NAME_STATIC)
.map(|s| {
s.entries()
.materialize()
.get()
.into_iter()
.map(|(id, _)| id.to_string())
.collect()
})
.unwrap_or_default()
}

#[test]
fn a_restart_after_a_reap_keeps_mail_under_fourteen_days() {
let (server, ctx) = booted_server();
Expand Down
Loading