@sjawhar/claude-envoy 1.15.0 → 1.16.0
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/.claude-plugin/plugin.json +1 -1
- package/.omp-plugin/plugin.json +1 -1
- package/AGENTS.md +42 -9
- package/README.md +23 -7
- package/dist/dispatch.js +23 -3
- package/dist/envoy-channel.js +116 -37
- package/dist/session-hook.js +6 -1
- package/package.json +1 -1
- package/scripts/smoke-clear-rebind.sh +9 -4
- package/src/envoy-channel-server.ts +148 -48
- package/src/legion-claim.ts +103 -4
- package/src/session-identity.ts +32 -12
package/.omp-plugin/plugin.json
CHANGED
package/AGENTS.md
CHANGED
|
@@ -119,7 +119,7 @@ Dispatch asks stay on Dispatch.
|
|
|
119
119
|
| Real Claude Code test | `tests/answers-claude.test.ts`, `/.claude-code-pin`, `/.github/actions/install-claude-code` | The `answers` hook on the real binary: Claude Code runs with this package as `--plugin-dir`, an isolated HOME and `CLAUDE_CONFIG_DIR`, and one stand-in as its Messages endpoint (`ANTHROPIC_BASE_URL`, server-sent events as the API streams them) and Dispatch (`DISPATCH_URL`); each case reads the list off the Messages request after the moment: startup (`claude -p --session-id`), `--resume <id>`, `/compact` sent in stream-json as `legion claude-bridge` sends turns, and a subagent (the stand-in answers with an `Agent` tool call, the API name of Claude Code's Task tool). `LEGION_TEST_CLAUDE` names the binary; the test skips without it, except on GitHub Actions, where the claude-envoy job installs the release `.claude-code-pin` pins (`bun add` into the runner's temp directory). A pin bump that changes a request shape is fixed in that file. |
|
|
120
120
|
| Inbound channel server | `src/envoy-channel-server.ts`, `src/envoy-off.ts` | MCP capability, notification queue, NATS registration, session-id handoff, role persistence, rejected-frame replies, and the Envoy tools, on the envoy.json Claude Code's rule picks (`@legion/envoy-client/session-settings`); why a section is off, and that inbound messages are off, goes to stderr once as `envoy-channel: <notice>`. `runEnvoyChannelServer` chooses between that server and the two reduced modes of `src/envoy-off.ts`. With Envoy off it runs `serveEnvoyOff` instead: an MCP server that lists no tool, refuses every call with `Envoy is off for this session: <why>` (the section's notice or error), opens no NATS connection and calls no listener, and exits 0 when stdin ends or on SIGTERM, SIGINT or SIGHUP. With Envoy on and inbound messages off (`inbound.off`: no NATS server, from `envoy.natsUrls` or `ENVOY_NATS_URL`, or its seed refused), it serves the tools on `inboundOffChannelSession`: no NATS connection, nothing registered with the listener, an empty inbox, `envoy_subscribe` refused naming why, and a role `envoy_role_set` claims still persisted. |
|
|
121
121
|
| Channel gate | `src/claude-channel.ts` | Whether Claude Code loaded this server as a channel, which it never tells the server: the command line of the nearest ancestor whose program is `claude` (`claudeCommandLine`; Claude Code may run the server through a shell prefix or a `bun` shim), read from `/proc` (exact, with its stdout's terminal and its directory) or `ps` (split on whitespace), against Claude Code's own gate as of 2.1.301: a `plugin:claude-envoy@…` entry when `CLAUDE_PLUGIN_ROOT` says it runs as a plugin, any `server:` entry from a bare config; `--channels` entries before development ones; `--dangerously-load-development-channels` only in an interactive session (`asksForPrintMode`, the arguments skipped as Claude Code's scanner skips them, and a terminal on stdout where `/proc` shows it); the marketplace it is loaded from (`loadedMarketplace`: `inline` for `--plugin-dir`, else the cache's, compared after Claude Code's cache-name sanitizing); for `--channels`, `allowedChannelPlugins` (object or `plugin@marketplace` entries, each invalid one dropped alone) from the managed source that owns it (`remote-settings.json`, else `managed-settings.json` with its non-dot drop-ins; under `managedSourcesBehavior: "merge"` the highest that sets it). Not loaded, the session registers `capabilities: []`. `ENVOY_CHANNEL_LOADED=1`/`0` overrides it. A command line it cannot read, or no `claude` ancestor, counts as loaded, and so does one Node overwrote with `process.title = "claude"` (on Linux, the title alone followed by NUL padding, whatever the executable's name; elsewhere one word from a program named `node…`). |
|
|
122
|
-
| Session identity | `src/session-identity.ts`, `src/session-state.ts`, `src/claude-session.ts` | `SessionIdentity` is the one mutable id holder; handoff file `sessions/<claude-pid>/session-id`; role file `roles/<url-encoded session id>.json` (`readPersistedRole`, `writePersistedRole`; the suffix is what keeps a session id of `.` or `..` inside `roles/`, since `encodeURIComponent` leaves a dot alone). `CLAUDE_CODE_SESSION_ID`, or explicit `ENVOY_SESSION_ID` for QA. `src/session-state.ts` holds what the state files share and nothing that imports zod: `writeAtomicStateFile`, and `pruneStateDirectory(directory, isStale)`, the one ENOENT-tolerant sweep behind all three of them — handoffs by liveness (a `sessions/<pid>` whose Claude process is gone, anything else there left alone), titles and roles by age (`writtenLongAgo`, `STATE_FILE_MAX_AGE_MS`, thirty days). The role sweep exists because exactly one path removes a role file by hand — a soft `setRole` the listener refuses
|
|
122
|
+
| Session identity | `src/session-identity.ts`, `src/session-state.ts`, `src/claude-session.ts` | `SessionIdentity` is the one mutable id holder; handoff file `sessions/<claude-pid>/session-id`; role file `roles/<url-encoded session id>.json` (`readPersistedRole`, `writePersistedRole`; the suffix is what keeps a session id of `.` or `..` inside `roles/`, since `encodeURIComponent` leaves a dot alone), whose record carries the writing process's `client_instance_id` beside the session id and the role, optional so a record written before the field loads unchanged. `CLAUDE_CODE_SESSION_ID`, or explicit `ENVOY_SESSION_ID` for QA. `src/session-state.ts` holds what the state files share and nothing that imports zod: `writeAtomicStateFile`, and `pruneStateDirectory(directory, isStale)`, the one ENOENT-tolerant sweep behind all three of them — handoffs by liveness (a `sessions/<pid>` whose Claude process is gone, anything else there left alone), titles and roles by age (`writtenLongAgo`, `STATE_FILE_MAX_AGE_MS`, thirty days). The role sweep exists because exactly one path removes a role file by hand — a soft `setRole` the listener refuses naming a session that is **not** the one the record is for, which says that session no longer holds the role. A refusal naming the record's own session keeps the file: the role is then held under that id by another process sharing this state directory, and the refused process goes on pressing its claim, refreshing the record's mtime on each heartbeat as a confirmed holder does, so the age sweep never reaches a record a process is still claiming for. The handoff sweep cannot reach either, keyed as it is by session id with no process left to ask about. All three run at startup in `startChannelSession`, each under its own `.catch` that logs and swallows: the heartbeat timer is not armed yet, so housekeeping must never cost a session its channel. |
|
|
123
123
|
| Session title | `src/session-title.ts`, `src/session-state.ts` | The title every registration sends, which Dispatch's Agents page shows in place of the id: `ENVOY_SESSION_TITLE` on one line when it holds a character that shows; else the name the person running the session gave it (`claude -n`, `--name`, `/rename`), alone, with no short id after it, since a name a person chose needs nothing to tell it apart; else a name and the current id's first eight characters (a function of the id, so it follows a `/clear` handoff, and keeps two sessions in one tmux window apart). The chosen name is read, through `readChosenTitleSync`, from the `titles/<url-encoded session id>.txt` file the hooks write under `CLAUDE_PLUGIN_DATA` (keyed by id, like the role file, so `claude --resume <id>` finds it and a `/clear`'s new id starts without one; the suffix keeps a session id of `.` or `..` inside `titles/`, since `encodeURIComponent` leaves a dot alone), and every writer and reader goes through `sessionTitleFile` so the file the hook writes is the file the server reads; that read is synchronous and swallows every error, because a cosmetic title must leave the session on its fallback rather than fail the registration carrying it, and the name goes through the same `oneLine`/`visible` pair the other sources do, so a blank or invisible one falls through to the fallback while a multi-line one is put on one line and kept. The fallback's name is the pane's tmux name (`TMUX` and `TMUX_PANE`, one bounded `tmux -u -S <socket> display-message` at startup, used only when the answering server's pid is the one in `TMUX` and the pane's process is one of this process's ancestors (`isAncestor`); `session:window` when the window's `automatic-rename` is off, else the session, never a window index; for grouped sessions the group's name), else the project directory's name (outside tmux, a refused or missing answer, or a session named only by digits in a window nobody named). That name is worked out once as the server starts; the chosen name is asked for on every call instead, so a rename reaches the page on the session's next prompt plus a heartbeat, with no watcher anywhere. `pruneStaleSessionTitles` drops title files untouched for thirty days at startup, through the sweep the handoff and role prunes share and under the same best-effort guard, since nothing else deletes a file keyed by a session id. The listener replaces the stored title on every registration, and `register` also stamps it (`stampTitle`: `writeSessionTitle` in the session's Dispatch state directory) once per id, where the `dispatch` command reads it for the `session_title` on its writes; with inbound messages or Envoy off, nothing registers, so `runEnvoyChannelServer` stamps it once for the starting id (a `/clear` there leaves the new id unstamped); nothing is stamped when the session starts with Dispatch off or under `ENVOY_SESSION_ID`. |
|
|
124
124
|
| NATS topic consumer | `src/channel-forwarder.ts` | Dedupe through the shared `createDeliveryDedupe`: a re-send by its `dedupe_key` (never by `event_id` alone, which the listener mints fresh for every send, so a Dispatch Retry would pass as new), and a second copy of one publish arriving on overlapping subscriptions by its `event_id`; surface the envelope's topic, and drain safely at shutdown. The promise the dedupe keeps is stated on `DELIVERY_DUPLICATE_WINDOW_MS` in `packages/contracts`. |
|
|
125
125
|
| Consumer delivery | `src/envoy-channel-server.ts` (`openRelay`, `deliverRelayed`, `renewRelayEpoch`, `relaysCoreTopic`), `src/replay-state.ts` (the `consumer` part of `replay-<session id>.json`), `../envoy-client/src/consumer-delivery.ts`, `../../scripts/e2e/envoy-session-delivery.sh` | The listener's relay of the session's durable consumer ("Overview"): the relay subscription and epoch, each frame's `received`, `done` and `nak`, and the persisted resume point. The repository's `scripts/e2e/envoy-session-delivery.sh` is the live proof under `legion claude-bridge` (`scripts/e2e/README.md`). |
|
|
@@ -285,17 +285,50 @@ above says how to run it).
|
|
|
285
285
|
the last signal Claude Code gives there, and the bridge's inbox answer in a Legion pane, which
|
|
286
286
|
comes once Claude Code re-emits the delivery (`--replay-user-messages`).
|
|
287
287
|
- Preserve the held role at `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json` (keyed by session id so
|
|
288
|
-
`claude --resume <id>` finds it)
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
`claude --resume <id>` finds it), with this process's client instance id in the record beside
|
|
289
|
+
the session id and the role, since two `claude` processes of one user read one state directory
|
|
290
|
+
and a record keyed by session id alone names no writer; soft-reclaim it with its
|
|
291
|
+
`previous_session_id` after registration, then reassert it on each registration heartbeat. A
|
|
292
|
+
record another process wrote is pressed only while this process holds no role, and a record
|
|
293
|
+
from a build before the writer was named is pressed as it always was. The listener grants such
|
|
294
|
+
a claim only when that predecessor has no live registry entry, when its live entry carries this
|
|
291
295
|
process's own client instance id, or when either side carries no instance id. A predecessor
|
|
292
296
|
still live under another client instance keeps the role, and the claim is refused naming it.
|
|
293
297
|
That refusal keeps the predecessor's role file, because the record belongs to the live holder.
|
|
294
|
-
A
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
A claim of a role already held under this session's own id is decided on the standing claim:
|
|
299
|
+
granted to the process that claim names, granted once nobody renewed it inside the listener's
|
|
300
|
+
live window, and otherwise refused naming this very session. The re-assertion confirms the
|
|
301
|
+
role only when the lookup names this session *and* this process and reports no hand-back,
|
|
302
|
+
since a claim another process made under this id is not this one's to renew and a claim the
|
|
303
|
+
listener marked handed back is nobody's to renew; a refusal naming this session keeps the role,
|
|
304
|
+
refreshes the record's mtime and asks again on the next heartbeat, and only a refusal naming a
|
|
305
|
+
different session ends the role and removes the file. The restore keeps asking on the same
|
|
306
|
+
rule: a refusal naming this session's own id leaves the restore pending on `roleTransferFrom`,
|
|
307
|
+
which every heartbeat presses again until the claim is granted, since one claim at boot and no
|
|
308
|
+
more would cost a `--resume` or a channel-server restart inside the live window its role for
|
|
309
|
+
the life of the process. The Legion pane's claim waits the same refusal out rather than
|
|
310
|
+
stopping the pane, asking every `ROLE_CLAIM_RETRY_MS` (10 s) for the live window and one
|
|
311
|
+
attempt more, so the grant lands inside the daemon's registration deadline for the pane's
|
|
312
|
+
`claims/ready` — `worker_boot_timeout_seconds` × `worker_boot_registration_deadline_intervals`
|
|
313
|
+
from the registration — and it throws only on a refusal naming a different session. The
|
|
314
|
+
lookup's rule and the record's are the client contract's own,
|
|
315
|
+
`roleHeldBy` and `mayActOnRoleRecord` in `@legion/envoy-client/transport`, which
|
|
316
|
+
`packages/pi-envoy` calls too and which read an empty instance id as none, the lookup leaving
|
|
317
|
+
`client_instance_id` out where the claim names no process. Every claim and every topic
|
|
318
|
+
hand-back states this process's instance too, so a release frees only the role claims this
|
|
319
|
+
process holds, and a release the holder's own renewal beat under each of the route's three
|
|
320
|
+
conditional deletes fails rather than reporting one it did not make. The registration that
|
|
321
|
+
renews those claims renews every one of them whatever topics it carries, and renews no claim
|
|
322
|
+
the listener has marked handed back: what says a role was given up is the mark a hand-back
|
|
323
|
+
leaves on a claim it could not free. This process writes no such mark — it gives a role up by
|
|
324
|
+
deregistering the session, not by releasing the lane — but a client built before a release
|
|
325
|
+
stated its process can leave one on this session's claim, and the re-assertion above claims
|
|
326
|
+
the role back on the next heartbeat, its grant a fresh record with no mark.
|
|
327
|
+
The handoff directory `sessions/<claude-pid>/` is pruned at startup when its pid is gone —
|
|
328
|
+
never deleted on shutdown, because Claude Code restarts the server inside the same `claude`
|
|
329
|
+
process — and the two id-keyed directories, `roles/` and `titles/`, are pruned there too at
|
|
330
|
+
thirty days, since a session that ends while it still holds its role deletes neither file
|
|
331
|
+
itself.
|
|
299
332
|
- Shared logic remains shared: use `sessionDispatchSettings`, `formatOpenAsksSummary`,
|
|
300
333
|
`postDeliveryReply`, `mergeInterestSources`, and `sendConfirmationText`. No Dispatch call goes
|
|
301
334
|
through the MCP server; the `dispatch` command runs the shared executor, and a `dispatch_*` tool
|
package/README.md
CHANGED
|
@@ -186,19 +186,35 @@ events and sends them to the current Claude Code session as supported
|
|
|
186
186
|
- A resumed server rebuilds the interests its session id already registered. `envoy_list` reports
|
|
187
187
|
the union of live NATS subscriptions and registry interests with each topic's `source` (`live`,
|
|
188
188
|
`registry`, `both`).
|
|
189
|
-
- `envoy_role_set` persists the held role in `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json
|
|
189
|
+
- `envoy_role_set` persists the held role in `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json`,
|
|
190
|
+
which names the writing process's client instance beside the session id and the role.
|
|
190
191
|
`claude --resume <session-id>` finds that file and soft-reclaims the role with the saved
|
|
191
|
-
`previous_session_id`; a plain relaunch is a new session and inherits nothing.
|
|
192
|
+
`previous_session_id`; a plain relaunch is a new session and inherits nothing. A record another
|
|
193
|
+
process wrote is pressed only while this process holds no role, since two `claude` processes of
|
|
194
|
+
one user share the state directory and a record is keyed by session id alone. The listener
|
|
192
195
|
moves the role off that predecessor only when the predecessor has no live registration, when
|
|
193
196
|
its live registration carries this process's own client instance id, or when either side
|
|
194
197
|
carries no instance id. A predecessor another process still runs keeps the role, and the claim
|
|
195
198
|
is refused naming it: this session goes on running and registered without the role, claims it
|
|
196
199
|
again only on an explicit claim, and keeps the predecessor's role file, since that record
|
|
197
|
-
belongs to the live holder.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
belongs to the live holder. A claim of a role already held under this session's own id is
|
|
201
|
+
decided on the standing claim: the listener grants it to the process that claim names, grants
|
|
202
|
+
it once nobody renewed that claim inside its live window, and otherwise refuses it naming this
|
|
203
|
+
very session, which keeps the role and the record here and asks again on the next heartbeat.
|
|
204
|
+
Only a refusal naming a different session ends the role and removes the record. Every healthy
|
|
205
|
+
registration heartbeat checks and reasserts the role if the listener lost it, and it confirms
|
|
206
|
+
the role only when the lookup names this session *and* this process and reports no hand-back
|
|
207
|
+
(`handed_back`); each heartbeat also renews the claims this process holds, whatever topics
|
|
208
|
+
that registration carries, and renews no claim the listener has marked handed back. A client
|
|
209
|
+
built before a release stated its process can hand this session's role lane back without
|
|
210
|
+
freeing the claim, which the listener marks rather than frees, so the mark is read as a role
|
|
211
|
+
this process does not hold and the next heartbeat claims it back, its grant a fresh record
|
|
212
|
+
with no mark. This process hands no lane back itself. Shutdown (stdin end, SIGTERM, SIGINT, or the SIGHUP a
|
|
213
|
+
closing pane sends the whole process group) drains NATS and deregisters the session, naming
|
|
214
|
+
this process's client instance id, so the listener removes the registry entry only while it
|
|
215
|
+
still carries that id or none, and every topic hand-back names it too, so a release frees only
|
|
216
|
+
the role claims this process holds; a release the holder's own renewal beat under each of the
|
|
217
|
+
route's three conditional deletes fails rather than reporting one it did not make.
|
|
202
218
|
- `/clear` mints a new Claude session id, but a stdio MCP server keeps the `CLAUDE_CODE_SESSION_ID`
|
|
203
219
|
it was spawned with. The SessionStart hook writes the current id to
|
|
204
220
|
`${CLAUDE_PLUGIN_DATA}/sessions/<claude-pid>/session-id` (keyed by the `claude` process both the
|
package/dist/dispatch.js
CHANGED
|
@@ -16821,6 +16821,7 @@ function dedupeKeyNamesItsEvent(envelope) {
|
|
|
16821
16821
|
}
|
|
16822
16822
|
// ../contracts/src/envoy-session.ts
|
|
16823
16823
|
var SESSION_HEARTBEAT_MS = 90000;
|
|
16824
|
+
var SESSION_LIVE_WINDOW_MS = 300000;
|
|
16824
16825
|
var LISTENER_CALL_TIMEOUT_MS = 5000;
|
|
16825
16826
|
var LISTENER_CALL_RETRY_DELAY_MS = 250;
|
|
16826
16827
|
var LISTENER_CALL_ATTEMPTS = 2;
|
|
@@ -27836,7 +27837,9 @@ var RoleWireSchema = object({
|
|
|
27836
27837
|
host: string2().optional(),
|
|
27837
27838
|
last_seen: number2().int(),
|
|
27838
27839
|
state: WireStateSchema,
|
|
27839
|
-
holder_state: SessionStateSchema.optional()
|
|
27840
|
+
holder_state: SessionStateSchema.optional(),
|
|
27841
|
+
client_instance_id: string2().optional(),
|
|
27842
|
+
handed_back: boolean2().optional()
|
|
27840
27843
|
});
|
|
27841
27844
|
var RoleHeldWireSchema = object({
|
|
27842
27845
|
error: string2(),
|
|
@@ -27915,6 +27918,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
|
|
|
27915
27918
|
function holderStateOf(role) {
|
|
27916
27919
|
return role.holder_state ?? role.state;
|
|
27917
27920
|
}
|
|
27921
|
+
function namedProcess(id) {
|
|
27922
|
+
return id === "" ? undefined : id;
|
|
27923
|
+
}
|
|
27924
|
+
function roleHeldBy(info, sessionID, clientInstanceID) {
|
|
27925
|
+
if (info === undefined || info.holder !== sessionID || info.handed_back === true)
|
|
27926
|
+
return false;
|
|
27927
|
+
const holding = namedProcess(info.client_instance_id);
|
|
27928
|
+
return holding === undefined || holding === namedProcess(clientInstanceID);
|
|
27929
|
+
}
|
|
27930
|
+
function mayActOnRoleRecord(writer, clientInstanceID, holdsRole) {
|
|
27931
|
+
const wrote = namedProcess(writer);
|
|
27932
|
+
if (wrote === undefined || wrote === namedProcess(clientInstanceID))
|
|
27933
|
+
return true;
|
|
27934
|
+
return !holdsRole;
|
|
27935
|
+
}
|
|
27918
27936
|
function newClientInstanceID() {
|
|
27919
27937
|
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
27920
27938
|
}
|
|
@@ -28110,7 +28128,8 @@ function createEnvoyClient(config) {
|
|
|
28110
28128
|
unsubscribe: async (input) => {
|
|
28111
28129
|
await post("/v1/interests/unsubscribe", {
|
|
28112
28130
|
session_id: input.sessionID,
|
|
28113
|
-
topics: expandSubscriptionTopics(input.topics)
|
|
28131
|
+
topics: expandSubscriptionTopics(input.topics),
|
|
28132
|
+
...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
|
|
28114
28133
|
}, { actingSession: input.sessionID });
|
|
28115
28134
|
},
|
|
28116
28135
|
getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
|
|
@@ -28180,7 +28199,8 @@ function createEnvoyClient(config) {
|
|
|
28180
28199
|
session_id: input.sessionID,
|
|
28181
28200
|
role: input.role,
|
|
28182
28201
|
...soft ? { soft: true } : {},
|
|
28183
|
-
...previous === "" ? {} : { previous_session_id: previous }
|
|
28202
|
+
...previous === "" ? {} : { previous_session_id: previous },
|
|
28203
|
+
...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
|
|
28184
28204
|
};
|
|
28185
28205
|
let raw;
|
|
28186
28206
|
try {
|
package/dist/envoy-channel.js
CHANGED
|
@@ -38470,6 +38470,7 @@ function dedupeKeyNamesItsEvent(envelope) {
|
|
|
38470
38470
|
}
|
|
38471
38471
|
// ../contracts/src/envoy-session.ts
|
|
38472
38472
|
var SESSION_HEARTBEAT_MS = 90000;
|
|
38473
|
+
var SESSION_LIVE_WINDOW_MS = 300000;
|
|
38473
38474
|
var LISTENER_CALL_TIMEOUT_MS = 5000;
|
|
38474
38475
|
var LISTENER_CALL_RETRY_DELAY_MS = 250;
|
|
38475
38476
|
var LISTENER_CALL_ATTEMPTS = 2;
|
|
@@ -44814,7 +44815,9 @@ var RoleWireSchema = object({
|
|
|
44814
44815
|
host: string2().optional(),
|
|
44815
44816
|
last_seen: number2().int(),
|
|
44816
44817
|
state: WireStateSchema,
|
|
44817
|
-
holder_state: SessionStateSchema.optional()
|
|
44818
|
+
holder_state: SessionStateSchema.optional(),
|
|
44819
|
+
client_instance_id: string2().optional(),
|
|
44820
|
+
handed_back: boolean2().optional()
|
|
44818
44821
|
});
|
|
44819
44822
|
var RoleHeldWireSchema = object({
|
|
44820
44823
|
error: string2(),
|
|
@@ -44893,6 +44896,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
|
|
|
44893
44896
|
function holderStateOf(role) {
|
|
44894
44897
|
return role.holder_state ?? role.state;
|
|
44895
44898
|
}
|
|
44899
|
+
function namedProcess(id) {
|
|
44900
|
+
return id === "" ? undefined : id;
|
|
44901
|
+
}
|
|
44902
|
+
function roleHeldBy(info, sessionID, clientInstanceID) {
|
|
44903
|
+
if (info === undefined || info.holder !== sessionID || info.handed_back === true)
|
|
44904
|
+
return false;
|
|
44905
|
+
const holding = namedProcess(info.client_instance_id);
|
|
44906
|
+
return holding === undefined || holding === namedProcess(clientInstanceID);
|
|
44907
|
+
}
|
|
44908
|
+
function mayActOnRoleRecord(writer, clientInstanceID, holdsRole) {
|
|
44909
|
+
const wrote = namedProcess(writer);
|
|
44910
|
+
if (wrote === undefined || wrote === namedProcess(clientInstanceID))
|
|
44911
|
+
return true;
|
|
44912
|
+
return !holdsRole;
|
|
44913
|
+
}
|
|
44896
44914
|
function newClientInstanceID() {
|
|
44897
44915
|
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
44898
44916
|
}
|
|
@@ -45088,7 +45106,8 @@ function createEnvoyClient(config) {
|
|
|
45088
45106
|
unsubscribe: async (input) => {
|
|
45089
45107
|
await post("/v1/interests/unsubscribe", {
|
|
45090
45108
|
session_id: input.sessionID,
|
|
45091
|
-
topics: expandSubscriptionTopics(input.topics)
|
|
45109
|
+
topics: expandSubscriptionTopics(input.topics),
|
|
45110
|
+
...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
|
|
45092
45111
|
}, { actingSession: input.sessionID });
|
|
45093
45112
|
},
|
|
45094
45113
|
getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
|
|
@@ -45158,7 +45177,8 @@ function createEnvoyClient(config) {
|
|
|
45158
45177
|
session_id: input.sessionID,
|
|
45159
45178
|
role: input.role,
|
|
45160
45179
|
...soft ? { soft: true } : {},
|
|
45161
|
-
...previous === "" ? {} : { previous_session_id: previous }
|
|
45180
|
+
...previous === "" ? {} : { previous_session_id: previous },
|
|
45181
|
+
...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
|
|
45162
45182
|
};
|
|
45163
45183
|
let raw;
|
|
45164
45184
|
try {
|
|
@@ -55191,7 +55211,7 @@ var import_nats2 = __toESM(require_mod4(), 1);
|
|
|
55191
55211
|
|
|
55192
55212
|
// package.json
|
|
55193
55213
|
var name = "@sjawhar/claude-envoy";
|
|
55194
|
-
var version2 = "1.
|
|
55214
|
+
var version2 = "1.16.0";
|
|
55195
55215
|
var legion = {
|
|
55196
55216
|
daemonApiVersion: 56
|
|
55197
55217
|
};
|
|
@@ -55880,7 +55900,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
|
|
|
55880
55900
|
}
|
|
55881
55901
|
|
|
55882
55902
|
// src/session-identity.ts
|
|
55883
|
-
var PersistedRole = object({
|
|
55903
|
+
var PersistedRole = object({
|
|
55904
|
+
session_id: string2().min(1),
|
|
55905
|
+
role: string2().min(1),
|
|
55906
|
+
client_instance_id: string2().min(1).optional()
|
|
55907
|
+
});
|
|
55884
55908
|
|
|
55885
55909
|
class SessionIdentity {
|
|
55886
55910
|
directory;
|
|
@@ -56123,9 +56147,13 @@ async function waitForSavedTranscript(transcript, signal, pollMs = 100) {
|
|
|
56123
56147
|
function isFile(file) {
|
|
56124
56148
|
return stat2(file).then((info) => info.isFile(), () => false);
|
|
56125
56149
|
}
|
|
56150
|
+
var ROLE_CLAIM_RETRY_MS = 1e4;
|
|
56151
|
+
var ROLE_CLAIM_WAIT_MS = SESSION_LIVE_WINDOW_MS + ROLE_CLAIM_RETRY_MS;
|
|
56126
56152
|
function startLegionClaim(deps) {
|
|
56127
56153
|
let capability;
|
|
56128
56154
|
const { sessionID } = deps;
|
|
56155
|
+
const roleWaitMs = deps.roleWaitMs ?? ROLE_CLAIM_WAIT_MS;
|
|
56156
|
+
const roleRetryMs = deps.roleRetryMs ?? ROLE_CLAIM_RETRY_MS;
|
|
56129
56157
|
const boot = async () => {
|
|
56130
56158
|
const transcript = await waitForTranscript(deps.pane.configDir, sessionID, deps.signal, deps.pollMs);
|
|
56131
56159
|
if (deps.pane.launcherSaves) {
|
|
@@ -56144,16 +56172,39 @@ function startLegionClaim(deps) {
|
|
|
56144
56172
|
capability = registered;
|
|
56145
56173
|
},
|
|
56146
56174
|
claimRole: async (role) => {
|
|
56147
|
-
const
|
|
56148
|
-
|
|
56149
|
-
|
|
56175
|
+
const deadline = Date.now() + roleWaitMs;
|
|
56176
|
+
let waited = false;
|
|
56177
|
+
for (;; ) {
|
|
56178
|
+
const claimed = await deps.client.setRole({
|
|
56179
|
+
sessionID,
|
|
56180
|
+
role,
|
|
56181
|
+
soft: true,
|
|
56182
|
+
clientInstanceID: deps.clientInstanceID
|
|
56183
|
+
});
|
|
56184
|
+
if (claimed.claimed)
|
|
56185
|
+
break;
|
|
56186
|
+
if (claimed.holder !== sessionID) {
|
|
56187
|
+
throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`);
|
|
56188
|
+
}
|
|
56189
|
+
const left = deadline - Date.now();
|
|
56190
|
+
if (left <= 0) {
|
|
56191
|
+
throw new Error(`Envoy role ${role} is still claimed under this session by another process after ${roleWaitMs} ms of asking`);
|
|
56192
|
+
}
|
|
56193
|
+
if (!waited) {
|
|
56194
|
+
waited = true;
|
|
56195
|
+
deps.log(`Envoy role ${role} is claimed under this session by the process this pane replaced; asking again every ${roleRetryMs} ms for up to ${roleWaitMs} ms, until nobody has answered for that claim inside the listener's live window`);
|
|
56196
|
+
}
|
|
56197
|
+
await delay(Math.min(roleRetryMs, left), undefined, { signal: deps.signal });
|
|
56150
56198
|
}
|
|
56151
56199
|
await deps.session.rememberRole(role);
|
|
56152
56200
|
},
|
|
56153
56201
|
onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
|
|
56154
56202
|
log: deps.log,
|
|
56155
56203
|
stopping: "stopping the pane so the daemon launches it again",
|
|
56156
|
-
stop:
|
|
56204
|
+
stop: () => {
|
|
56205
|
+
if (!deps.signal.aborted)
|
|
56206
|
+
deps.stop();
|
|
56207
|
+
}
|
|
56157
56208
|
});
|
|
56158
56209
|
};
|
|
56159
56210
|
const booted = boot().catch((error) => {
|
|
@@ -57861,7 +57912,11 @@ async function startChannelSession(options) {
|
|
|
57861
57912
|
relay.delivery.setFollowed(registeredTopics());
|
|
57862
57913
|
return {
|
|
57863
57914
|
dropping,
|
|
57864
|
-
unsubscribing: serialized(() => options.client.unsubscribe({
|
|
57915
|
+
unsubscribing: serialized(() => options.client.unsubscribe({
|
|
57916
|
+
sessionID: identity.id,
|
|
57917
|
+
topics,
|
|
57918
|
+
clientInstanceID: options.clientInstanceID
|
|
57919
|
+
}))
|
|
57865
57920
|
};
|
|
57866
57921
|
}
|
|
57867
57922
|
function registeredTopics() {
|
|
@@ -57914,27 +57969,40 @@ async function startChannelSession(options) {
|
|
|
57914
57969
|
const result = await registering;
|
|
57915
57970
|
takeDeliveryAnswer(sessionID, topics, asking, result);
|
|
57916
57971
|
};
|
|
57917
|
-
const
|
|
57972
|
+
const writeRoleRecord = (file, role) => writePersistedRole(file, {
|
|
57973
|
+
session_id: identity.id,
|
|
57974
|
+
role,
|
|
57975
|
+
client_instance_id: options.clientInstanceID
|
|
57976
|
+
});
|
|
57977
|
+
let roleTransferFrom;
|
|
57978
|
+
const restoreRole = async (previousSessionID) => {
|
|
57918
57979
|
const previousFile = roleStateFile(options.stateDirectory, previousSessionID);
|
|
57919
57980
|
const persisted = await readPersistedRole(previousFile);
|
|
57920
57981
|
if (persisted === undefined)
|
|
57921
|
-
return;
|
|
57982
|
+
return "settled";
|
|
57983
|
+
if (!mayActOnRoleRecord(persisted.client_instance_id, options.clientInstanceID, heldRole !== undefined)) {
|
|
57984
|
+
return "settled";
|
|
57985
|
+
}
|
|
57922
57986
|
const claim = await options.client.setRole({
|
|
57923
57987
|
sessionID: identity.id,
|
|
57924
57988
|
role: persisted.role,
|
|
57925
57989
|
soft: true,
|
|
57926
|
-
previousSessionID: persisted.session_id
|
|
57990
|
+
previousSessionID: persisted.session_id,
|
|
57991
|
+
clientInstanceID: options.clientInstanceID
|
|
57927
57992
|
});
|
|
57928
57993
|
if (!claim.claimed) {
|
|
57994
|
+
if (claim.holder === identity.id)
|
|
57995
|
+
return "pending";
|
|
57929
57996
|
if (claim.holder !== persisted.session_id)
|
|
57930
57997
|
await rm2(previousFile, { force: true });
|
|
57931
|
-
return;
|
|
57998
|
+
return "settled";
|
|
57932
57999
|
}
|
|
57933
58000
|
heldRole = persisted.role;
|
|
57934
58001
|
const currentFile = roleStateFile(options.stateDirectory, identity.id);
|
|
57935
58002
|
if (previousFile !== currentFile)
|
|
57936
58003
|
await rm2(previousFile, { force: true });
|
|
57937
|
-
await
|
|
58004
|
+
await writeRoleRecord(currentFile, persisted.role);
|
|
58005
|
+
return "settled";
|
|
57938
58006
|
};
|
|
57939
58007
|
let regained;
|
|
57940
58008
|
const fireRegained = (role, reason) => {
|
|
@@ -57964,8 +58032,8 @@ async function startChannelSession(options) {
|
|
|
57964
58032
|
if (heldRole === undefined)
|
|
57965
58033
|
return;
|
|
57966
58034
|
const role = heldRole;
|
|
57967
|
-
const
|
|
57968
|
-
if (
|
|
58035
|
+
const holding = await options.client.getRole(role).catch(undefinedOnNotFound);
|
|
58036
|
+
if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
|
|
57969
58037
|
await refreshRoleRecord();
|
|
57970
58038
|
if (afterOutage)
|
|
57971
58039
|
fireRegained(role, "reregistered");
|
|
@@ -57974,24 +58042,27 @@ async function startChannelSession(options) {
|
|
|
57974
58042
|
const result = await options.client.setRole({
|
|
57975
58043
|
sessionID: identity.id,
|
|
57976
58044
|
role,
|
|
57977
|
-
soft: true
|
|
58045
|
+
soft: true,
|
|
58046
|
+
clientInstanceID: options.clientInstanceID
|
|
57978
58047
|
});
|
|
57979
58048
|
if (!result.claimed) {
|
|
57980
|
-
|
|
57981
|
-
|
|
58049
|
+
if (result.holder !== identity.id) {
|
|
58050
|
+
heldRole = undefined;
|
|
58051
|
+
await rm2(roleStateFile(options.stateDirectory, identity.id), { force: true });
|
|
58052
|
+
return;
|
|
58053
|
+
}
|
|
58054
|
+
await refreshRoleRecord();
|
|
57982
58055
|
return;
|
|
57983
58056
|
}
|
|
57984
58057
|
fireRegained(role, "reclaimed");
|
|
57985
58058
|
};
|
|
57986
|
-
let roleTransferFrom;
|
|
57987
58059
|
let roleTransferFailureReported = false;
|
|
57988
58060
|
const transferRole = async () => {
|
|
57989
58061
|
const from = roleTransferFrom;
|
|
57990
58062
|
if (from === undefined)
|
|
57991
58063
|
return;
|
|
57992
58064
|
try {
|
|
57993
|
-
await restoreRole(from);
|
|
57994
|
-
roleTransferFrom = undefined;
|
|
58065
|
+
roleTransferFrom = await restoreRole(from) === "pending" ? from : undefined;
|
|
57995
58066
|
roleTransferFailureReported = false;
|
|
57996
58067
|
} catch (error) {
|
|
57997
58068
|
if (!roleTransferFailureReported) {
|
|
@@ -58056,7 +58127,11 @@ async function startChannelSession(options) {
|
|
|
58056
58127
|
const drifted = registry.topics.filter((topic) => topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic));
|
|
58057
58128
|
if (drifted.length === 0)
|
|
58058
58129
|
return;
|
|
58059
|
-
await options.client.unsubscribe({
|
|
58130
|
+
await options.client.unsubscribe({
|
|
58131
|
+
sessionID: identity.id,
|
|
58132
|
+
topics: drifted,
|
|
58133
|
+
clientInstanceID: options.clientInstanceID
|
|
58134
|
+
});
|
|
58060
58135
|
};
|
|
58061
58136
|
let heartbeatInFlight = false;
|
|
58062
58137
|
let heartbeatRequested = false;
|
|
@@ -58145,17 +58220,19 @@ async function startChannelSession(options) {
|
|
|
58145
58220
|
await options.connection.flush();
|
|
58146
58221
|
await serialized(async () => {
|
|
58147
58222
|
await register();
|
|
58148
|
-
|
|
58223
|
+
try {
|
|
58224
|
+
roleTransferFrom = await restoreRole(identity.id) === "pending" ? identity.id : undefined;
|
|
58225
|
+
} catch (error) {
|
|
58149
58226
|
if (error instanceof UnparseableRoleRecordError) {
|
|
58150
58227
|
process.stderr.write(`envoy-channel: ${messageFor(error)}; the session starts without a role
|
|
58151
58228
|
`);
|
|
58152
|
-
|
|
58153
|
-
|
|
58154
|
-
|
|
58155
|
-
|
|
58156
|
-
process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
|
|
58229
|
+
} else {
|
|
58230
|
+
roleTransferFrom = identity.id;
|
|
58231
|
+
roleTransferFailureReported = true;
|
|
58232
|
+
process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
|
|
58157
58233
|
`);
|
|
58158
|
-
|
|
58234
|
+
}
|
|
58235
|
+
}
|
|
58159
58236
|
});
|
|
58160
58237
|
triggerReplay();
|
|
58161
58238
|
for (const prune of [
|
|
@@ -58230,10 +58307,7 @@ async function startChannelSession(options) {
|
|
|
58230
58307
|
},
|
|
58231
58308
|
async rememberRole(role) {
|
|
58232
58309
|
heldRole = role;
|
|
58233
|
-
await
|
|
58234
|
-
session_id: identity.id,
|
|
58235
|
-
role
|
|
58236
|
-
});
|
|
58310
|
+
await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role);
|
|
58237
58311
|
},
|
|
58238
58312
|
async shutdown() {
|
|
58239
58313
|
if (shuttingDown)
|
|
@@ -58332,7 +58406,11 @@ async function executeEnvoyTool(runtime, name, input) {
|
|
|
58332
58406
|
}
|
|
58333
58407
|
case EnvoyToolOperation.setRole: {
|
|
58334
58408
|
const args = parseArguments(spec, input);
|
|
58335
|
-
const result = await runtime.client.setRole({
|
|
58409
|
+
const result = await runtime.client.setRole({
|
|
58410
|
+
sessionID: identity.id,
|
|
58411
|
+
role: args.role,
|
|
58412
|
+
clientInstanceID: runtime.clientInstanceID
|
|
58413
|
+
});
|
|
58336
58414
|
if (!result.claimed)
|
|
58337
58415
|
throw new Error(`role ${args.role} is held by ${result.holder}`);
|
|
58338
58416
|
await runtime.session.rememberRole(args.role);
|
|
@@ -58546,13 +58624,14 @@ async function runEnvoyChannelServer() {
|
|
|
58546
58624
|
...configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}
|
|
58547
58625
|
});
|
|
58548
58626
|
}
|
|
58549
|
-
runtime = { identity, client, session };
|
|
58627
|
+
runtime = { identity, client, session, clientInstanceID };
|
|
58550
58628
|
if (pane !== undefined && daemon !== undefined && !stopping) {
|
|
58551
58629
|
claim = startLegionClaim({
|
|
58552
58630
|
pane,
|
|
58553
58631
|
sessionID: identity.id,
|
|
58554
58632
|
daemon,
|
|
58555
58633
|
client,
|
|
58634
|
+
clientInstanceID,
|
|
58556
58635
|
session,
|
|
58557
58636
|
signal: booting.signal,
|
|
58558
58637
|
log: logLegion,
|
package/dist/session-hook.js
CHANGED
|
@@ -15820,6 +15820,7 @@ function dedupeKeyNamesItsEvent(envelope) {
|
|
|
15820
15820
|
}
|
|
15821
15821
|
// ../contracts/src/envoy-session.ts
|
|
15822
15822
|
var SESSION_HEARTBEAT_MS = 90000;
|
|
15823
|
+
var SESSION_LIVE_WINDOW_MS = 300000;
|
|
15823
15824
|
var LISTENER_CALL_TIMEOUT_MS = 5000;
|
|
15824
15825
|
var LISTENER_CALL_RETRY_DELAY_MS = 250;
|
|
15825
15826
|
var LISTENER_CALL_ATTEMPTS = 2;
|
|
@@ -26872,7 +26873,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
|
|
|
26872
26873
|
}
|
|
26873
26874
|
|
|
26874
26875
|
// src/session-identity.ts
|
|
26875
|
-
var PersistedRole = object({
|
|
26876
|
+
var PersistedRole = object({
|
|
26877
|
+
session_id: string2().min(1),
|
|
26878
|
+
role: string2().min(1),
|
|
26879
|
+
client_instance_id: string2().min(1).optional()
|
|
26880
|
+
});
|
|
26876
26881
|
|
|
26877
26882
|
class SessionIdentity {
|
|
26878
26883
|
directory;
|
package/package.json
CHANGED
|
@@ -49,11 +49,16 @@ cleanup() {
|
|
|
49
49
|
sleep 0.2
|
|
50
50
|
done
|
|
51
51
|
done
|
|
52
|
-
# A deregistered session keeps its interest entry until the listener reaps it;
|
|
53
|
-
#
|
|
52
|
+
# A deregistered session keeps its interest entry until the listener reaps it; the
|
|
53
|
+
# administrator's route removes the entry now. The empty-topics unsubscribe no longer serves a
|
|
54
|
+
# cleanup that names no process: it frees the role claims of the client instance its body
|
|
55
|
+
# states, and this script can state none — each channel server mints its own, no listener route
|
|
56
|
+
# reports it, and both processes have exited by the time this runs. DELETE
|
|
57
|
+
# /v1/interests/<session_id> removes a session's interests whoever holds them, which is what a
|
|
58
|
+
# cleanup wants.
|
|
54
59
|
for id in "${session_ids[@]}"; do
|
|
55
|
-
curl --connect-timeout 2 --max-time 5 -fsS -o /dev/null -X
|
|
56
|
-
"${envoy_url}/v1/interests
|
|
60
|
+
curl --connect-timeout 2 --max-time 5 -fsS -o /dev/null -X DELETE \
|
|
61
|
+
"${envoy_url}/v1/interests/${id}" || true
|
|
57
62
|
done
|
|
58
63
|
# Claude Code keeps a transcript directory per project path, named after it.
|
|
59
64
|
rm -rf "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/${workdir//[^A-Za-z0-9]/-}" "$workdir"
|
|
@@ -71,8 +71,10 @@ import {
|
|
|
71
71
|
type EnvoyClient,
|
|
72
72
|
expandSubscriptionTopics,
|
|
73
73
|
holderStateOf,
|
|
74
|
+
mayActOnRoleRecord,
|
|
74
75
|
mergeInterestSources,
|
|
75
76
|
newClientInstanceID,
|
|
77
|
+
roleHeldBy,
|
|
76
78
|
type SubscribeResult,
|
|
77
79
|
} from "@legion/envoy-client/transport"
|
|
78
80
|
import { createLegionDaemonClient } from "@legion/pi-shared/daemon-client"
|
|
@@ -1031,8 +1033,15 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1031
1033
|
if (relay?.sessionID === identity.id) relay.delivery.setFollowed(registeredTopics())
|
|
1032
1034
|
return {
|
|
1033
1035
|
dropping,
|
|
1036
|
+
// The release names this process, so the listener frees the role claim of this process
|
|
1037
|
+
// alone where one of these topics is a role's: a second process that resumed this session
|
|
1038
|
+
// id keeps the role it holds.
|
|
1034
1039
|
unsubscribing: serialized(() =>
|
|
1035
|
-
options.client.unsubscribe({
|
|
1040
|
+
options.client.unsubscribe({
|
|
1041
|
+
sessionID: identity.id,
|
|
1042
|
+
topics,
|
|
1043
|
+
clientInstanceID: options.clientInstanceID,
|
|
1044
|
+
}),
|
|
1036
1045
|
),
|
|
1037
1046
|
}
|
|
1038
1047
|
}
|
|
@@ -1110,6 +1119,27 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1110
1119
|
takeDeliveryAnswer(sessionID, topics, asking, result)
|
|
1111
1120
|
}
|
|
1112
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* Writes `file` as this session's role record, naming this process as its writer, so a second
|
|
1124
|
+
* `claude` reading the same state directory — one that resumed this session id — tells its own
|
|
1125
|
+
* record from this one's. The destination is the caller's, because `restoreRole` resolves it
|
|
1126
|
+
* before it removes the predecessor's file while the record's `session_id` is read after.
|
|
1127
|
+
*/
|
|
1128
|
+
const writeRoleRecord = (file: string, role: string): Promise<void> =>
|
|
1129
|
+
writePersistedRole(file, {
|
|
1130
|
+
session_id: identity.id,
|
|
1131
|
+
role,
|
|
1132
|
+
client_instance_id: options.clientInstanceID,
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* The id whose role record is still to be taken back, read by `transferRole` on every
|
|
1137
|
+
* heartbeat: a handoff has moved away from it, a restore of it failed, or a restore of it was
|
|
1138
|
+
* refused by a standing claim under this session's own id. Written by each caller of
|
|
1139
|
+
* `restoreRole` from what that call answered, and by `adoptHandoff` for the id it left.
|
|
1140
|
+
*/
|
|
1141
|
+
let roleTransferFrom: string | undefined
|
|
1142
|
+
|
|
1113
1143
|
/**
|
|
1114
1144
|
* Take back the role persisted for `previousSessionID` (this id on `--resume`,
|
|
1115
1145
|
* the pre-`/clear` id on a handoff) with a soft claim naming that predecessor. The listener
|
|
@@ -1117,30 +1147,64 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1117
1147
|
* carries this process's own client instance id, or when either side carries none; a
|
|
1118
1148
|
* predecessor still live under another client instance keeps the role.
|
|
1119
1149
|
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1150
|
+
* Answers `"pending"` when the role is still to be taken back from that id and only time
|
|
1151
|
+
* decides it, `"settled"` when nothing more is to be asked of that record. The caller keeps
|
|
1152
|
+
* the retry: a restore writes none of its own.
|
|
1153
|
+
*
|
|
1154
|
+
* A record another process wrote is pressed only while this process holds no role
|
|
1155
|
+
* (`mayActOnRoleRecord`, the one rule both clients read). One state directory is read by two
|
|
1156
|
+
* processes whenever a second `claude` resumes a live session's id, and a record is keyed by
|
|
1157
|
+
* session id alone, so the record found here can be the other process's claim rather than a
|
|
1158
|
+
* predecessor of this one's (dispatch://INFRA-783). With no role of its own this process has
|
|
1159
|
+
* nothing to lose by asking, and the listener decides between the two; holding one, it leaves
|
|
1160
|
+
* that record where its writer can still use it. A record written before the writer was named
|
|
1161
|
+
* is nobody's in particular and is pressed as it always was.
|
|
1162
|
+
*
|
|
1163
|
+
* A refusal naming this session's own id is the standing claim of the process this one
|
|
1164
|
+
* replaced — the `claude` that `--resume` continues, or the channel server this one restarted
|
|
1165
|
+
* — which renewed that claim at its last heartbeat and so still stands inside the listener's
|
|
1166
|
+
* live window. The listener grants it the moment nobody has answered for it inside that
|
|
1167
|
+
* window, so that refusal is the `"pending"` answer and every heartbeat asks again until it
|
|
1168
|
+
* is granted: one claim at boot and no more would cost this process its role for the life of
|
|
1169
|
+
* the process, on every `--resume` and every restart inside the window. The record stays
|
|
1170
|
+
* where it is, because the pending claim is read back out of it.
|
|
1171
|
+
*
|
|
1172
|
+
* Which is why a refusal naming the predecessor itself keeps the record too: it belongs to
|
|
1173
|
+
* that live holder, another process sharing this state directory, and deleting it would cost
|
|
1174
|
+
* that process its only way back to the role. A refusal naming any other session says the
|
|
1123
1175
|
* predecessor demonstrably no longer holds it, so its record goes — the one path that removes
|
|
1124
1176
|
* a role record by hand.
|
|
1125
1177
|
*/
|
|
1126
|
-
const restoreRole = async (previousSessionID
|
|
1178
|
+
const restoreRole = async (previousSessionID: string): Promise<"settled" | "pending"> => {
|
|
1127
1179
|
const previousFile = roleStateFile(options.stateDirectory, previousSessionID)
|
|
1128
1180
|
const persisted = await readPersistedRole(previousFile)
|
|
1129
|
-
if (persisted === undefined) return
|
|
1181
|
+
if (persisted === undefined) return "settled"
|
|
1182
|
+
if (
|
|
1183
|
+
!mayActOnRoleRecord(
|
|
1184
|
+
persisted.client_instance_id,
|
|
1185
|
+
options.clientInstanceID,
|
|
1186
|
+
heldRole !== undefined,
|
|
1187
|
+
)
|
|
1188
|
+
) {
|
|
1189
|
+
return "settled"
|
|
1190
|
+
}
|
|
1130
1191
|
const claim = await options.client.setRole({
|
|
1131
1192
|
sessionID: identity.id,
|
|
1132
1193
|
role: persisted.role,
|
|
1133
1194
|
soft: true,
|
|
1134
1195
|
previousSessionID: persisted.session_id,
|
|
1196
|
+
clientInstanceID: options.clientInstanceID,
|
|
1135
1197
|
})
|
|
1136
1198
|
if (!claim.claimed) {
|
|
1199
|
+
if (claim.holder === identity.id) return "pending"
|
|
1137
1200
|
if (claim.holder !== persisted.session_id) await rm(previousFile, { force: true })
|
|
1138
|
-
return
|
|
1201
|
+
return "settled"
|
|
1139
1202
|
}
|
|
1140
1203
|
heldRole = persisted.role
|
|
1141
1204
|
const currentFile = roleStateFile(options.stateDirectory, identity.id)
|
|
1142
1205
|
if (previousFile !== currentFile) await rm(previousFile, { force: true })
|
|
1143
|
-
await
|
|
1206
|
+
await writeRoleRecord(currentFile, persisted.role)
|
|
1207
|
+
return "settled"
|
|
1144
1208
|
}
|
|
1145
1209
|
|
|
1146
1210
|
let regained: RoleRegainHook | undefined
|
|
@@ -1187,15 +1251,29 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1187
1251
|
}
|
|
1188
1252
|
}
|
|
1189
1253
|
|
|
1190
|
-
/**
|
|
1191
|
-
*
|
|
1254
|
+
/**
|
|
1255
|
+
* Re-establishes this session as the live holder of the role it holds, on every heartbeat.
|
|
1256
|
+
* `afterOutage`: the last tick's registration failed, so a holder the listener still names may
|
|
1257
|
+
* have been answered "no holder" meanwhile.
|
|
1258
|
+
*
|
|
1259
|
+
* The lookup confirms the role only when it names this session *and* this process
|
|
1260
|
+
* (`roleHeldBy`, the one rule both clients read): a claim standing under this very session id
|
|
1261
|
+
* that another process made is not this one's to renew, and reading it as confirmation is how
|
|
1262
|
+
* a lane kept heartbeating while a continuation's claim held the role and then lapsed
|
|
1263
|
+
* (dispatch://INFRA-783). Anything else is claimed again, soft.
|
|
1264
|
+
*
|
|
1265
|
+
* A refusal naming a different session ends the role here: that session holds it, and this
|
|
1266
|
+
* one's record is the only thing that would claim otherwise, so it goes. A refusal naming this
|
|
1267
|
+
* session is the standing claim of another process under this id, which the listener grants
|
|
1268
|
+
* the moment that process stops renewing it; the role and its record stay, and the next
|
|
1269
|
+
* heartbeat asks again. The record's mtime is refreshed as it is on a confirmed holder, so the
|
|
1270
|
+
* age sweep never expires a record this process is still pressing a claim for.
|
|
1271
|
+
*/
|
|
1192
1272
|
const reassertRole = async (afterOutage: boolean): Promise<void> => {
|
|
1193
1273
|
if (heldRole === undefined) return
|
|
1194
1274
|
const role = heldRole
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
.then((info) => info.holder, undefinedOnNotFound)
|
|
1198
|
-
if (holder === identity.id) {
|
|
1275
|
+
const holding = await options.client.getRole(role).catch(undefinedOnNotFound)
|
|
1276
|
+
if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
|
|
1199
1277
|
await refreshRoleRecord()
|
|
1200
1278
|
if (afterOutage) fireRegained(role, "reregistered")
|
|
1201
1279
|
return
|
|
@@ -1204,40 +1282,41 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1204
1282
|
sessionID: identity.id,
|
|
1205
1283
|
role,
|
|
1206
1284
|
soft: true,
|
|
1285
|
+
clientInstanceID: options.clientInstanceID,
|
|
1207
1286
|
})
|
|
1208
1287
|
if (!result.claimed) {
|
|
1209
|
-
|
|
1210
|
-
|
|
1288
|
+
if (result.holder !== identity.id) {
|
|
1289
|
+
heldRole = undefined
|
|
1290
|
+
await rm(roleStateFile(options.stateDirectory, identity.id), { force: true })
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
await refreshRoleRecord()
|
|
1211
1294
|
return
|
|
1212
1295
|
}
|
|
1213
1296
|
fireRegained(role, "reclaimed")
|
|
1214
1297
|
}
|
|
1215
1298
|
|
|
1216
|
-
/**
|
|
1217
|
-
* The id a handoff moved away from, until its role has been taken back; a
|
|
1218
|
-
* handoff whose registration failed leaves it for the next heartbeat.
|
|
1219
|
-
*/
|
|
1220
|
-
let roleTransferFrom: string | undefined
|
|
1221
1299
|
/** Whether a failure to take back the queued role has been reported since the last success. */
|
|
1222
1300
|
let roleTransferFailureReported = false
|
|
1223
1301
|
/**
|
|
1224
1302
|
* Best-effort, unlike `syncRegisteredInterests` behind it on the heartbeat. A failed transfer
|
|
1225
|
-
* keeps its own retry, `roleTransferFrom`, which clears only once `restoreRole`
|
|
1226
|
-
* throw would add nothing to that and would end the tick
|
|
1227
|
-
* on every tick for as long as the failure lasts — a
|
|
1228
|
-
* subscriptions and a registry-side unsubscribe that
|
|
1229
|
-
* session works without its role meanwhile; it does not
|
|
1230
|
-
* has no queue of its own and needs the failed tick,
|
|
1303
|
+
* keeps its own retry, `roleTransferFrom`, which this clears only once `restoreRole` answers
|
|
1304
|
+
* that the record is settled; a throw would add nothing to that and would end the tick
|
|
1305
|
+
* before the interests reconciliation, on every tick for as long as the failure lasts — a
|
|
1306
|
+
* resumed session would never adopt its subscriptions and a registry-side unsubscribe that
|
|
1307
|
+
* failed would never be cleared. The session works without its role meanwhile; it does not
|
|
1308
|
+
* work without its interests, whose read has no queue of its own and needs the failed tick,
|
|
1309
|
+
* its report and its retry.
|
|
1231
1310
|
*/
|
|
1232
1311
|
const transferRole = async (): Promise<void> => {
|
|
1233
1312
|
const from = roleTransferFrom
|
|
1234
1313
|
if (from === undefined) return
|
|
1235
1314
|
try {
|
|
1236
|
-
await restoreRole(from)
|
|
1237
|
-
roleTransferFrom = undefined
|
|
1315
|
+
roleTransferFrom = (await restoreRole(from)) === "pending" ? from : undefined
|
|
1238
1316
|
roleTransferFailureReported = false
|
|
1239
1317
|
} catch (error) {
|
|
1240
|
-
//
|
|
1318
|
+
// `roleTransferFrom` is untouched, so the next heartbeat asks again.
|
|
1319
|
+
// Said once per failure run.
|
|
1241
1320
|
if (!roleTransferFailureReported) {
|
|
1242
1321
|
roleTransferFailureReported = true
|
|
1243
1322
|
process.stderr.write(
|
|
@@ -1390,7 +1469,11 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1390
1469
|
topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic),
|
|
1391
1470
|
)
|
|
1392
1471
|
if (drifted.length === 0) return
|
|
1393
|
-
await options.client.unsubscribe({
|
|
1472
|
+
await options.client.unsubscribe({
|
|
1473
|
+
sessionID: identity.id,
|
|
1474
|
+
topics: drifted,
|
|
1475
|
+
clientInstanceID: options.clientInstanceID,
|
|
1476
|
+
})
|
|
1394
1477
|
}
|
|
1395
1478
|
|
|
1396
1479
|
// One tick at a time, and at most one pending behind it. `serialized` already
|
|
@@ -1525,22 +1608,31 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1525
1608
|
// failure but one: a listener answer that is not JSON or misses the schema, an unreadable
|
|
1526
1609
|
// role file, any transient error. The one is a role file whose bytes cannot be parsed, which
|
|
1527
1610
|
// only `readPersistedRole` can tell apart: no later read of the same bytes succeeds, so it is
|
|
1528
|
-
// not queued, and it costs this session its role and nothing more.
|
|
1529
|
-
|
|
1611
|
+
// not queued, and it costs this session its role and nothing more. A claim the listener
|
|
1612
|
+
// refused naming this very session is queued the same way, on the `"pending"` the restore
|
|
1613
|
+
// answers: the process this one resumed renewed that claim at its last heartbeat, and the
|
|
1614
|
+
// role comes back from the standing claim itself rather than from the reaper, which never
|
|
1615
|
+
// frees it while this process keeps that session alive. The listener grants it to a process
|
|
1616
|
+
// that keeps asking once that renewal is older than the live window
|
|
1617
|
+
// (`roleOwnSessionVerdict`), so the role is back one live window after that renewal, plus
|
|
1618
|
+
// up to the one heartbeat tick it takes this process to ask again.
|
|
1619
|
+
try {
|
|
1620
|
+
roleTransferFrom = (await restoreRole(identity.id)) === "pending" ? identity.id : undefined
|
|
1621
|
+
} catch (error) {
|
|
1530
1622
|
if (error instanceof UnparseableRoleRecordError) {
|
|
1531
1623
|
process.stderr.write(
|
|
1532
1624
|
`envoy-channel: ${messageFor(error)}; the session starts without a role\n`,
|
|
1533
1625
|
)
|
|
1534
|
-
|
|
1626
|
+
} else {
|
|
1627
|
+
roleTransferFrom = identity.id
|
|
1628
|
+
// This is the first failure of the run `transferRole` reports, so it says nothing more
|
|
1629
|
+
// until the retry has succeeded once.
|
|
1630
|
+
roleTransferFailureReported = true
|
|
1631
|
+
process.stderr.write(
|
|
1632
|
+
`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries\n`,
|
|
1633
|
+
)
|
|
1535
1634
|
}
|
|
1536
|
-
|
|
1537
|
-
// This is the first failure of the run `transferRole` reports, so it says nothing more
|
|
1538
|
-
// until the retry has succeeded once.
|
|
1539
|
-
roleTransferFailureReported = true
|
|
1540
|
-
process.stderr.write(
|
|
1541
|
-
`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries\n`,
|
|
1542
|
-
)
|
|
1543
|
-
})
|
|
1635
|
+
}
|
|
1544
1636
|
})
|
|
1545
1637
|
// The first sweep, once the listener knows what the session follows: what was stored while it
|
|
1546
1638
|
// was away (a resume included), or, for a new session, its cursors' start at the stream's end.
|
|
@@ -1637,10 +1729,7 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1637
1729
|
},
|
|
1638
1730
|
async rememberRole(role) {
|
|
1639
1731
|
heldRole = role
|
|
1640
|
-
await
|
|
1641
|
-
session_id: identity.id,
|
|
1642
|
-
role,
|
|
1643
|
-
})
|
|
1732
|
+
await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role)
|
|
1644
1733
|
},
|
|
1645
1734
|
async shutdown() {
|
|
1646
1735
|
if (shuttingDown) return
|
|
@@ -1681,6 +1770,9 @@ export interface ChannelToolRuntime {
|
|
|
1681
1770
|
readonly identity: SessionIdentity
|
|
1682
1771
|
readonly client: EnvoyClient
|
|
1683
1772
|
readonly session: ChannelSession
|
|
1773
|
+
/** The process the tools act as: the id every registration and every claim of this channel
|
|
1774
|
+
* server carries (`ChannelSessionOptions.clientInstanceID`). */
|
|
1775
|
+
readonly clientInstanceID: string
|
|
1684
1776
|
}
|
|
1685
1777
|
export async function executeEnvoyTool(
|
|
1686
1778
|
runtime: ChannelToolRuntime,
|
|
@@ -1768,7 +1860,14 @@ export async function executeEnvoyTool(
|
|
|
1768
1860
|
}
|
|
1769
1861
|
case EnvoyToolOperation.setRole: {
|
|
1770
1862
|
const args = parseArguments(spec, input)
|
|
1771
|
-
|
|
1863
|
+
// The claim names this process, as every claim this server makes does: the listener
|
|
1864
|
+
// records it as the role's holder, so a second process on this session id cannot be
|
|
1865
|
+
// mistaken for the one holding the role.
|
|
1866
|
+
const result = await runtime.client.setRole({
|
|
1867
|
+
sessionID: identity.id,
|
|
1868
|
+
role: args.role,
|
|
1869
|
+
clientInstanceID: runtime.clientInstanceID,
|
|
1870
|
+
})
|
|
1772
1871
|
if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`)
|
|
1773
1872
|
await runtime.session.rememberRole(args.role)
|
|
1774
1873
|
return result.interest
|
|
@@ -2086,13 +2185,14 @@ export async function runEnvoyChannelServer(): Promise<void> {
|
|
|
2086
2185
|
...(configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}),
|
|
2087
2186
|
})
|
|
2088
2187
|
}
|
|
2089
|
-
runtime = { identity, client, session }
|
|
2188
|
+
runtime = { identity, client, session, clientInstanceID }
|
|
2090
2189
|
if (pane !== undefined && daemon !== undefined && !stopping) {
|
|
2091
2190
|
claim = startLegionClaim({
|
|
2092
2191
|
pane,
|
|
2093
2192
|
sessionID: identity.id,
|
|
2094
2193
|
daemon,
|
|
2095
2194
|
client,
|
|
2195
|
+
clientInstanceID,
|
|
2096
2196
|
session,
|
|
2097
2197
|
signal: booting.signal,
|
|
2098
2198
|
log: logLegion,
|
package/src/legion-claim.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, stat } from "node:fs/promises"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises"
|
|
4
|
+
import { SESSION_LIVE_WINDOW_MS } from "@legion/contracts"
|
|
4
5
|
import { messageFor } from "@legion/envoy-client/errors"
|
|
5
6
|
import { readSecretFile } from "@legion/envoy-client/secret-file"
|
|
6
7
|
import type { EnvoyClient } from "@legion/envoy-client/transport"
|
|
@@ -132,6 +133,42 @@ function isFile(file: string): Promise<boolean> {
|
|
|
132
133
|
)
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* How long apart the attempts of {@link startLegionClaim}'s wait on a claim the listener refused
|
|
138
|
+
* naming this very session are.
|
|
139
|
+
*
|
|
140
|
+
* The wait runs between `claims/register` and `claims/ready`, and the daemon retires a pane that
|
|
141
|
+
* has not said it is ready `worker_boot_timeout_seconds` ×
|
|
142
|
+
* `worker_boot_registration_deadline_intervals` after its registration armed that deadline — 120
|
|
143
|
+
* s × 3 = 360 s with the shipped defaults (`packages/daemon/internal/config/config.go`, armed by
|
|
144
|
+
* `register` through `armRegistration` in `packages/daemon/internal/supervise`). So the interval
|
|
145
|
+
* is what stands between the grant and that deadline: the listener grants the standing claim on
|
|
146
|
+
* the first attempt that falls after the live window its last renewal bought it, which is at
|
|
147
|
+
* most `SESSION_LIVE_WINDOW_MS` after this pane registered, and every part of an interval past
|
|
148
|
+
* that point is dead waiting. At a heartbeat's cadence the attempt at 270 s is still refused
|
|
149
|
+
* whenever that renewal fell inside the 30 s before the registration, and the next one is at 360
|
|
150
|
+
* s — the deadline itself. At 10 s the grant lands inside 310 s of the registration, 50 s of
|
|
151
|
+
* room for `claims/ready` and its own retries.
|
|
152
|
+
*
|
|
153
|
+
* What the shorter interval costs: one `POST /v1/roles/set` per interval, a conditional write the
|
|
154
|
+
* listener refuses without touching the claim, at most about 30 of them, and only in a pane the
|
|
155
|
+
* daemon relaunched into its predecessor's standing claim — once per such relaunch, for at most
|
|
156
|
+
* one live window.
|
|
157
|
+
*/
|
|
158
|
+
export const ROLE_CLAIM_RETRY_MS = 10_000
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* How long that same wait keeps asking before it gives up and the pane is stopped: the live
|
|
162
|
+
* window and one attempt more, so the last attempt falls after the window the process this pane
|
|
163
|
+
* replaced could still have been answering for its claim in, even when it answered for it at the
|
|
164
|
+
* very moment this pane registered.
|
|
165
|
+
*
|
|
166
|
+
* Inside the daemon's registration deadline on purpose: a pane that cannot have its role writes
|
|
167
|
+
* why to its log and is launched again, rather than being retired by a deadline that would
|
|
168
|
+
* otherwise fire first and say nothing.
|
|
169
|
+
*/
|
|
170
|
+
export const ROLE_CLAIM_WAIT_MS = SESSION_LIVE_WINDOW_MS + ROLE_CLAIM_RETRY_MS
|
|
171
|
+
|
|
135
172
|
export interface LegionClaim {
|
|
136
173
|
/** The registered claim, or undefined until `claims/register` has answered. */
|
|
137
174
|
readonly capability: () => ClaimCapability | undefined
|
|
@@ -148,22 +185,44 @@ export interface LegionClaim {
|
|
|
148
185
|
* `claims/ready` with no capability report, again each time the heartbeat regains the role. A
|
|
149
186
|
* failure the core stops on ends the pane through `stop`; a failure before a registration is
|
|
150
187
|
* logged and leaves the pane to the daemon's registration deadline.
|
|
188
|
+
*
|
|
189
|
+
* A claim the listener refuses naming this very session is waited out rather than stopped on:
|
|
190
|
+
* the pane the daemon relaunched with `--resume` meets the standing claim of the process it
|
|
191
|
+
* replaced, and that claim is granted the moment nobody has answered for it inside the
|
|
192
|
+
* listener's live window (`roleWaitMs`). Stopping instead would hand the daemon a relaunch that
|
|
193
|
+
* meets the same claim, for a whole window of relaunches.
|
|
194
|
+
*
|
|
195
|
+
* The claim is asked again every {@link ROLE_CLAIM_RETRY_MS} for up to
|
|
196
|
+
* {@link ROLE_CLAIM_WAIT_MS}, both sized so the grant, and the give-up behind it, fall inside the
|
|
197
|
+
* daemon's registration deadline for this pane's `claims/ready`.
|
|
151
198
|
*/
|
|
152
199
|
export function startLegionClaim(deps: {
|
|
153
200
|
readonly pane: LegionPane
|
|
154
201
|
readonly sessionID: string
|
|
155
202
|
readonly daemon: LegionDaemonClient
|
|
156
203
|
readonly client: Pick<EnvoyClient, "setRole">
|
|
204
|
+
/** The channel server's own client instance: the id its registrations carry, which this
|
|
205
|
+
* claim names as the process holding the role. */
|
|
206
|
+
readonly clientInstanceID: string
|
|
157
207
|
readonly session: Pick<ChannelSession, "rememberRole" | "onRoleRegained">
|
|
158
208
|
readonly signal: AbortSignal
|
|
159
209
|
/** Writes one line to the pane's log, the server's stderr. */
|
|
160
210
|
readonly log: (line: string) => void
|
|
161
211
|
/** Ends the pane's Claude Code, this server's parent, so the daemon launches the claim again. */
|
|
162
212
|
readonly stop: () => void
|
|
213
|
+
/**
|
|
214
|
+
* How long a claim refused by a standing claim under this very session id keeps asking before
|
|
215
|
+
* it gives up and the pane is stopped: {@link ROLE_CLAIM_WAIT_MS}.
|
|
216
|
+
*/
|
|
217
|
+
readonly roleWaitMs?: number
|
|
218
|
+
/** How long between those attempts: {@link ROLE_CLAIM_RETRY_MS}. */
|
|
219
|
+
readonly roleRetryMs?: number
|
|
163
220
|
readonly pollMs?: number
|
|
164
221
|
}): LegionClaim {
|
|
165
222
|
let capability: ClaimCapability | undefined
|
|
166
223
|
const { sessionID } = deps
|
|
224
|
+
const roleWaitMs = deps.roleWaitMs ?? ROLE_CLAIM_WAIT_MS
|
|
225
|
+
const roleRetryMs = deps.roleRetryMs ?? ROLE_CLAIM_RETRY_MS
|
|
167
226
|
|
|
168
227
|
const boot = async (): Promise<void> => {
|
|
169
228
|
const transcript = await waitForTranscript(
|
|
@@ -188,16 +247,56 @@ export function startLegionClaim(deps: {
|
|
|
188
247
|
capability = registered
|
|
189
248
|
},
|
|
190
249
|
claimRole: async (role) => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
250
|
+
// Soft, so a role another live session holds is left with it, and naming this process,
|
|
251
|
+
// so the listener records the claim as this process's: a second `claude` that resumed
|
|
252
|
+
// this session id can then neither be taken for the holder nor release what it holds.
|
|
253
|
+
//
|
|
254
|
+
// A refusal naming this very session is that same claim seen from the other side: the
|
|
255
|
+
// process this pane was relaunched from renewed it at its last heartbeat, so it stands
|
|
256
|
+
// inside the listener's live window and the listener grants it to nobody else until
|
|
257
|
+
// that window passes with no answer for it. So the claim is asked again on the
|
|
258
|
+
// channel server's own heartbeat cadence until it is granted. Only a refusal naming a
|
|
259
|
+
// different session throws, which stops the pane: that role is another session's, and
|
|
260
|
+
// no amount of asking changes it.
|
|
261
|
+
const deadline = Date.now() + roleWaitMs
|
|
262
|
+
let waited = false
|
|
263
|
+
for (;;) {
|
|
264
|
+
const claimed = await deps.client.setRole({
|
|
265
|
+
sessionID,
|
|
266
|
+
role,
|
|
267
|
+
soft: true,
|
|
268
|
+
clientInstanceID: deps.clientInstanceID,
|
|
269
|
+
})
|
|
270
|
+
if (claimed.claimed) break
|
|
271
|
+
if (claimed.holder !== sessionID) {
|
|
272
|
+
throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`)
|
|
273
|
+
}
|
|
274
|
+
const left = deadline - Date.now()
|
|
275
|
+
if (left <= 0) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Envoy role ${role} is still claimed under this session by another process after ${roleWaitMs} ms of asking`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
if (!waited) {
|
|
281
|
+
waited = true
|
|
282
|
+
// Said once: the state is normal and self-resolving, and a line per attempt would
|
|
283
|
+
// fill the pane's log with the same sentence for a whole live window.
|
|
284
|
+
deps.log(
|
|
285
|
+
`Envoy role ${role} is claimed under this session by the process this pane replaced; asking again every ${roleRetryMs} ms for up to ${roleWaitMs} ms, until nobody has answered for that claim inside the listener's live window`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
await delay(Math.min(roleRetryMs, left), undefined, { signal: deps.signal })
|
|
194
289
|
}
|
|
195
290
|
await deps.session.rememberRole(role)
|
|
196
291
|
},
|
|
197
292
|
onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
|
|
198
293
|
log: deps.log,
|
|
199
294
|
stopping: "stopping the pane so the daemon launches it again",
|
|
200
|
-
|
|
295
|
+
// A boot the shutdown aborted stops nothing: this server is already going, and `stop`
|
|
296
|
+
// would SIGTERM the `claude` it runs under on its way out.
|
|
297
|
+
stop: () => {
|
|
298
|
+
if (!deps.signal.aborted) deps.stop()
|
|
299
|
+
},
|
|
201
300
|
})
|
|
202
301
|
}
|
|
203
302
|
|
package/src/session-identity.ts
CHANGED
|
@@ -4,7 +4,23 @@ import { hasErrnoCode, messageFor } from "@legion/envoy-client/errors"
|
|
|
4
4
|
import { z } from "zod"
|
|
5
5
|
import { pruneStateDirectory, writeAtomicStateFile, writtenLongAgo } from "./session-state"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The role record on disk: the session the role was claimed for, the role, and the process that
|
|
9
|
+
* wrote the record.
|
|
10
|
+
*
|
|
11
|
+
* `client_instance_id` is the writer's client instance — one Claude Code channel-server process,
|
|
12
|
+
* the same id its registrations and its claims carry. One state directory is read by two
|
|
13
|
+
* processes whenever a second `claude` resumes a live session's id, and the record is keyed by
|
|
14
|
+
* session id alone, so without the writer a process cannot tell its own record from the other's.
|
|
15
|
+
* It is optional because a record written before the field existed carries none: such a record
|
|
16
|
+
* loads and is read as written by no known process (`restoreRole` in the channel server presses
|
|
17
|
+
* it as it always did).
|
|
18
|
+
*/
|
|
19
|
+
const PersistedRole = z.object({
|
|
20
|
+
session_id: z.string().min(1),
|
|
21
|
+
role: z.string().min(1),
|
|
22
|
+
client_instance_id: z.string().min(1).optional(),
|
|
23
|
+
})
|
|
8
24
|
|
|
9
25
|
/**
|
|
10
26
|
* The session id the channel server acts as, held once and passed by reference.
|
|
@@ -72,9 +88,10 @@ export class UnparseableRoleRecordError extends Error {
|
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
/**
|
|
75
|
-
* The role a session held, from its role file; undefined when it has none.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
91
|
+
* The role a session held, from its role file; undefined when it has none. A record written
|
|
92
|
+
* before the writer was named carries no `client_instance_id` and loads unchanged. Bytes that do
|
|
93
|
+
* not parse throw `UnparseableRoleRecordError`; every other failure to read them (`EACCES`,
|
|
94
|
+
* `EISDIR`, any I/O error) propagates unchanged.
|
|
78
95
|
*/
|
|
79
96
|
export async function readPersistedRole(
|
|
80
97
|
roleFile: string,
|
|
@@ -137,14 +154,17 @@ export async function pruneStaleSessionHandoffs(stateDirectory: string): Promise
|
|
|
137
154
|
* title records follow, except the record of `currentSessionId` — the session running the sweep.
|
|
138
155
|
*
|
|
139
156
|
* A record's mtime is when its session was last seen holding the role: every heartbeat on which
|
|
140
|
-
* the listener still names the session as holder touches the record
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
157
|
+
* the listener still names the session as holder touches the record, and so does every heartbeat
|
|
158
|
+
* a refused process spends pressing its claim again (`refreshRoleRecord` in the channel server),
|
|
159
|
+
* so this age reaches only a record whose session has not confirmed its role for that long — in
|
|
160
|
+
* practice one that stopped running. Only one path removes a role file by hand — a soft
|
|
161
|
+
* `setRole` refused (`claimed: false`, the listener's 409) naming a session other than the one
|
|
162
|
+
* the record is for, which says that session demonstrably no longer holds the role — never any
|
|
163
|
+
* answer of the publish route, and never a refusal naming the record's own session, which is the
|
|
164
|
+
* role held under that id by another process. So a session that holds its role to the end leaves
|
|
165
|
+
* its file behind, and the handoff sweep cannot reach it: that one walks `sessions/<pid>/` and
|
|
166
|
+
* asks the operating system about a pid, while a role file is keyed by session id and has no
|
|
167
|
+
* process left to ask about.
|
|
148
168
|
*
|
|
149
169
|
* The sweep runs at startup, before any heartbeat has touched anything, and a session whose claim
|
|
150
170
|
* the listener refused there has not touched its own record either: age alone would expire the
|