[v0.1/P1] Move detached spawn to correlated Ops and make create atomic #20
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#20
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P1.1). Related to #5 and #6.
Why
Detached creation is a pool operation but currently uses uncorrelated
Session::Create, forcing the bridge to serialize spawns positionally. Window creation also exposes a pool-visible unattached gap between create and attach.Scope
Ops::Spawn,Spawned, and typed failure messages.Session::Createatomically create and attach.Ready { created }/AttachFailedambiguity with distinct typed outcomes.Sessionif no steady-state arm needs it.Acceptance criteria
skills/felisare updated.Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.Claim check
Accurate against HEAD (
69076d42). Nothing in the three post-snapshot commits touches this path.crates/felis-daemon/src/serve.rs:467-554:SessionMsg::Createreserves a slot (:485-489), spawns, registers viasession_task::spawn_session(:530-538), writesReady { info, created: true }uncorrelated (:545-549), then loops to wait for a follow-upAttach(:551-552comment: "Detached creation: loop to honor a follow-upAttach(GUI window) or EOF (sessions spawn)").docs/reference/ipc.md:643-650documents exactly this ("always detached ... A caller that wants a live view sendsAttachnext").crates/felis-protocol/proto/felis.proto:1072-1085:Correlation correlation = 100;with the comment "The slot exists for the family, and no arm uses it".Ready/AttachFailedare shared by Attach and Create and distinguished only by thecreatedbool (felis.proto:1123-1134).crates/felis-client-core/src/connector.rscreate_with:spawn_session(args)(one round trip) thenattach(created.id, AttachIntent::Deliberate)(a second). Between the daemon'sReadywrite and theAttacharriving, the session is registered, idle, and listable; a client crash or a failedReadywrite (serve.rs:545-549returnsErrafter registration) leaves a session nobody was ever told about.crates/felis-cli/src/cli_bridge.rs:1440-1443(Registry.session: Option<oneshot::Sender<…>>, "attributed positionally: one slot, held for the whole round trip") and:1583-1600(create_sessionrefuses a second spawn withinvalid_request"another spawn is already in flight on this bridge"). Acceptance criterion 1 (two concurrent bridge spawns attributed by request id) is currently impossible, not merely untested.serve/tests.rs:4353concurrent_creates_never_admit_past_the_session_capcovers the reservation; this issue is about attribution and atomicity, not admission.One nuance the issue's wording hides: "Failed create/attach cannot leave a new orphan session" must be read narrowly. Once a session has been attached, losing its window is the product (principle 3,
session-lifecycle.md:333-338), not an orphan. The orphan is a session whose creator never learned its id. With create+attach atomic, the id is only ever delivered in the attached state, and anOps::Spawnwhose reply is lost is a detached session thatsessions listshows, which is what a headless spawn wants anyway.Verdict
accept-with-changes. The direction is right and the change is breaking, so it belongs in the pre-2.0 window (#30). Changes to the written scope:
Ops::Spawnshould be an outcome oneof in the reply, not a new failure arm:OpsSpawned { oneof outcome { SessionInfo ok = 1; SpawnRefused refused = 2; } }withSpawnRefused { AttachFailure reason; string detail }. This mirrorsResolvedId(felis.proto:1243-1255) and keepsConnMsg::Error'sStreamErrorReasonset (InvalidRequest/TooManyStreams/Unavailable/Internal) from having to grow spawn-specific reasons.Ready { created }with two arms rather than one flag:SessionMsg::Attached { info }(answersAttach) andSessionMsg::Created { info }(answersCreate, already attached when sent).AttachFailedstays for both; the reason enum already distinguishes them.Sessionby hand before #48 decides the correlation shape; reserve it as part of #30's renumbering so the ledger records one reason.Principle 1 is not in question: this adds no capability, it fixes attribution of an existing one.
Approach
Protocol (
crates/felis-protocol)proto/felis.proto: addOpsSpawn { SpawnArgs args = 1; }/OpsSpawned(above) asOpsMsgarms 13/14; changeSessionMsgarms per verdict item 2; moveSpawnArgsdoc from the Session section to shared structs (it already lives at:500).src/messages.rs/src/convert.rs: domain enums and conversions;codec::Correlatedimpl forOpsMsg::Spawnis free (the wholeOpsMsgfamily is correlated).Daemon (
crates/felis-daemon/src/serve.rs)Createarm (:467-538: admit dims,try_reserve, mint id,spawn_with_args,spawn_session) intofn create_session(...) -> Result<(SessionId, SessionInfo), (AttachFailure, String)>.route_ops(:916area): addOpsMsg::Spawn { args } => create_session(...)mapped toOpsSpawned; gate it with the same mode rule asDestroy(mutating ops needConnectionMode::Ops,:788).wait_for_attachCreatearm: callcreate_session, then fall straight into the existingAttachsubscribe path (:555-600) with the fresh handle andlive_only: false, and answerCreated { info }only afterSubscribeReqsucceeds. Drop the "loop to honor a follow-up Attach" behaviour; aSession::AttachafterCreatedis then a phase violation (#46).Client core (
crates/felis-client-core/src/connector.rs)CreateSessionReqbecomes aCorrelatedRequestonOpsMsg::Spawn(used byfelis sessions spawnand the bridge).create_withsends oneSessionMsg::Createand interpretsCreated.AttachSessionReq::interpretmatchesAttached.Bridge (
crates/felis-cli/src/cli_bridge.rs)Registry.session(:1440-1443), the positional branch indispatch_payload, andcreate_session(:1583-1627);op_spawn(:834-852) becomesself.anchor.request(&OpsMsg::Spawn { args })and mapsSpawnRefusedtoat_capacity/invalid_requestexactly as:1611-1622does today.Docs cascade
docs/reference/ipc.mdSession section (:643-650,:730-740), Ops section (newSpawn/Spawnedbullet besideDestroy), the minor ledger (:1738+; if this lands before #30 it is a minor 6 row whose "older peer" column is cannot be sent, likeStatus; if after, it is 2.0 base).docs/explanation/architecture/ipc.md: record why create+attach is one message (the unattached gap) and why headless spawn moved toOps(attribution); "Revisit if" a client ever needs create-without-attach on a window connection.docs/explanation/architecture/session-lifecycle.md:141(thesessions spawnsentence),docs/reference/cli.mdspawn row,skills/felis/SKILL.mdbridgesessions.spawntext (drop any "one spawn at a time" caveat),CHANGELOG.md(wire + bridge behaviour).Tests
serve/tests.rs: (a) twoOps::Spawnon one connection with a factory that delays the first; replies arrive reversed and eachrequest_idmaps to its ownSessionInfo; (b) a windowCreateis followed immediately byRehydrateBeginon the same connection with noAttachsent; (c)a_create_past_the_session_cap_is_refused_and_executes_nothing(:4300) and the geometry test (:4199) re-pointed at both entry points; (d) aCreatewhose subscribe fails leavespool.len()unchanged.sessions.spawnrequests pipelined get tworesultobjects with their own ids.Dependencies
Spawnas anOpsMsgarm is the "new operation inside an existing conversation" case) and #48 (correlation shape; the reply must be a request reply, nothing else). Decisions only; their implementation can follow.Createdphase isAttached; land #20 first so #46 models the final flow, as #52 recommends).Risk/effort
L. Touches the daemon attach phase, the client-core request layer, and the bridge at once, with #46 reshaping the same driver. Main risk: regressing the window-launch path (
felis,felis -- cmd, transientrun/pipewhich also create) — every landing indial.rs:295-330passes throughcreate_with.Labels
Keep
priority/P1,release/v0.1.0. It is a wire break and must precede #30.Review amendments (round 1)
spawn_sessionregisters the session in the pool and starts its owner task before subscription. If the subscribe/attach step fails, the daemon mustpool.remove(id)and sendSessionCmd::Shutdownon the handle (the same pathsessions killuses atserve.rs:808) and await the owner task's exit, so the PTY child is reaped. Implement this as an RAII guard (Registered { pool, id, handle }) disarmed only after the subscription succeeds, so every early-return path rolls back. Test (d) becomes: aCreatewhose subscribe fails leavespool.len()unchanged and the session task has exited (join handle resolved) with the child reaped.Review amendments (round 2)
Drop.spawn_owned(session_task.rs:330-436) hands the ownerJoinHandleto a detached supervisor andSessionHandle(pool.rs:331-337) has no completion handle, andDropcannot await. Refactor:spawn_ownedreturns aSessionLifecycle { id, info, done: watch::Receiver<bool> }(the supervisor setsdonewhen the owner task exits and the child is reaped). Create/attach has a single failure exit that callsasync fn rollback(&mut self)on aRegisteredguard:pool.remove(id),cmd.send(SessionCmd::Shutdown), thendone.changed().awaitunder a bounded timeout (log and continue if the child ignoresSIGHUPwithin it).Dropremains only as a nonblocking fallback (try_send(Shutdown)) for panics. Test: the attach-failure test awaitscreatereturning the error, then assertsdoneis alreadytrue,pool.len()unchanged, and the fixture child's PID is gone.