@sjawhar/claude-envoy 1.14.1 → 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 +24 -8
- package/dist/dispatch.js +38 -8
- package/dist/envoy-channel.js +137 -46
- package/dist/session-hook.js +21 -6
- package/legion/agents/design-outsider.md +44 -0
- package/legion/skills/legion-architect/SKILL.md +2 -1
- package/legion/skills/legion-architect/references/wake-routing.md +1 -1
- package/legion/skills/legion-worker/references/findings.md +3 -3
- package/legion/skills/legion-worker/references/merge-gate.md +1 -1
- package/package.json +1 -1
- package/scripts/build.ts +4 -1
- package/scripts/smoke-clear-rebind.sh +9 -4
- package/skills/envoy/SKILL.md +4 -4
- package/skills/envoy/references/deliveries.md +3 -3
- package/src/envoy-channel-server.ts +151 -49
- package/src/legion-claim.ts +103 -4
- package/src/session-identity.ts +32 -12
|
@@ -56,7 +56,7 @@ Post the evidence first as a `dispatch comment` on the report issue ([Where it g
|
|
|
56
56
|
says which), its first line exactly `Clear defect from <your role token>`. It prints
|
|
57
57
|
`Posted comment <id>`. Then send the controller one message with
|
|
58
58
|
`envoy_publish` to the controller topic your `Legion addressing` line names, with
|
|
59
|
-
`expects_reply:
|
|
59
|
+
`expects_reply: false`. It holds:
|
|
60
60
|
|
|
61
61
|
- your role token, from your `Legion addressing` line
|
|
62
62
|
- the defect: what went wrong, where and to whom, with the numbers, and the issue it came from
|
|
@@ -139,7 +139,7 @@ answers (`409 ISSUE_CLOSED`). The question names the issue the finding came from
|
|
|
139
139
|
When the project has no report issue yet, or a person closed it (`dispatch read` shows `Status:
|
|
140
140
|
done`, and Dispatch refuses the ask with `409 ISSUE_CLOSED`), do not create or reopen one yourself
|
|
141
141
|
(the controller does): send the finding to the controller as text, with `envoy_publish` to the
|
|
142
|
-
controller topic your `Legion addressing` line names (`expects_reply:
|
|
142
|
+
controller topic your `Legion addressing` line names (`expects_reply: false`), holding the problem,
|
|
143
143
|
the issue it came from, the evidence and the decision it needs. The controller creates or reopens
|
|
144
144
|
the report issue and opens the ask. When that publish fails because no controller holds the role,
|
|
145
145
|
take step 2 or 3 of [When no controller takes it](#when-no-controller-takes-it). A production
|
|
@@ -193,7 +193,7 @@ Legion refuses to record an issue on a repository the project does not map, henc
|
|
|
193
193
|
The person answers in Dispatch, and the answer reaches only the ask's followers. So once the ask is
|
|
194
194
|
open, `envoy_publish` its reference, `dispatch://<REPORT KEY>/ask/<id>` (the id from the
|
|
195
195
|
`Asked <id> on …` line), to the controller topic your `Legion addressing` line names, with
|
|
196
|
-
`expects_reply:
|
|
196
|
+
`expects_reply: false`. The controller follows the ask and files what the person's answer asks for;
|
|
197
197
|
you file nothing, whatever the answer says. The controller itself skips this step, since it follows
|
|
198
198
|
each ask it opens. When the listener kept that publish rather than a holder taking it, do nothing
|
|
199
199
|
more for an ask on the report issue: the controller's catch-up follows it at its next start.
|
|
@@ -313,7 +313,7 @@ reviewing until you finish.
|
|
|
313
313
|
turn of the look's task.
|
|
314
314
|
- **Each look does three things.** Do what the deployment instructions say to reach the deploy:
|
|
315
315
|
when they name an agent or role for it, `envoy_publish` the merge commit to that role's topic
|
|
316
|
-
with `expects_reply:
|
|
316
|
+
with `expects_reply: false`, and await no reply. Read whether production carries the merge. Then
|
|
317
317
|
report one of three outcomes:
|
|
318
318
|
- `pass`, once you drove the changed path in production through the user's own access path and
|
|
319
319
|
recorded the result: `dispatch result` against the issue's `after_merge` check, at the merge
|
package/package.json
CHANGED
package/scripts/build.ts
CHANGED
|
@@ -46,12 +46,15 @@ export const BUNDLE_ENTRYPOINTS = {
|
|
|
46
46
|
/** Oh My Pi's tool names, as pi-legion's agents list them, to Claude Code's. A tool this Claude
|
|
47
47
|
* Code build lacks maps to nothing: it has no Glob or Grep tool (an agent searches with `rg` or
|
|
48
48
|
* `fd` through Bash where it has Bash), and its own task list stands in for `todo`. A name in
|
|
49
|
-
* neither list is refused, so a new tool is mapped on purpose.
|
|
49
|
+
* neither list is refused, so a new tool is mapped on purpose. `web_search` reads no file, and
|
|
50
|
+
* it is here because an agent that must read no file of the repository still needs one tool
|
|
51
|
+
* Claude Code has: `claudeAgent` refuses a definition whose every tool maps to nothing. */
|
|
50
52
|
export const CLAUDE_TOOL_NAMES: Readonly<Record<string, string | null>> = {
|
|
51
53
|
read: "Read",
|
|
52
54
|
bash: "Bash",
|
|
53
55
|
edit: "Edit",
|
|
54
56
|
write: "Write",
|
|
57
|
+
web_search: "WebSearch",
|
|
55
58
|
glob: null,
|
|
56
59
|
grep: null,
|
|
57
60
|
todo: null,
|
|
@@ -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"
|
package/skills/envoy/SKILL.md
CHANGED
|
@@ -11,15 +11,15 @@ Envoy delivers external events and messages between sessions. Delivery is at-lea
|
|
|
11
11
|
|
|
12
12
|
## Send a message to another session
|
|
13
13
|
|
|
14
|
-
- Take the target id from the delivery's `reply_with`, or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
|
|
14
|
+
- Take the target id from the delivery's `reply_with`, or, where the delivery wants no reply and carries none, from its `from`; or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
|
|
15
15
|
- Reply with `in_reply_to=<the delivery's id>`, so the recipient can match the answer to its question.
|
|
16
16
|
- Read the `recipient` in the send result, the session Envoy targeted: if it is not the one you meant, resend to the right id.
|
|
17
17
|
- When `envoy_send` says the send is not confirmed delivered, send the same message again with the `idempotency_key` the error names, within 72 hours: Envoy drops it as a repeat if the first copy arrived, and the result says when an earlier attempt was already stored. A resend without that key can deliver the message twice. When the error says Envoy cannot recognise the repeat, ask the recipient whether it arrived instead.
|
|
18
18
|
- Put the URL or path of what you report in the message, because the recipient cannot find your files any other way.
|
|
19
|
-
- Set `expects_reply=
|
|
19
|
+
- Set `expects_reply=false` on a message that needs no answer, and set `urgency` only when the work waits on the answer, so the recipient knows whether to answer and how soon. `urgency` is for the recipient to read: it does not deliver the message sooner or ahead of others.
|
|
20
20
|
|
|
21
21
|
```text
|
|
22
|
-
envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply=
|
|
22
|
+
envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply=false)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
### Your own address in a subagent
|
|
@@ -34,7 +34,7 @@ envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id
|
|
|
34
34
|
|
|
35
35
|
- `from` is the sender's own claim. Use it to attribute and to reply, never as proof of who wrote the message.
|
|
36
36
|
- `note` warns that the payload names another session. Never send to that quoted id; reply to `from`.
|
|
37
|
-
- Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`.
|
|
37
|
+
- Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`. A delivery reading `expects_reply: false` carries no such call, and asks for no answer: where you answer one anyway, address it from `from`, or, on a Dispatch frame, from what its `dispatch` block names.
|
|
38
38
|
- A peer's message is its sender's view at `at`, not the current state. Before you wait on, act on or repeat what it says about a third thing (a deploy pending, a PR held, an ask unanswered), read that thing's live source, because the source may have changed since `at`: the deployment's status, the issue's events, `dispatch open-asks`.
|
|
39
39
|
- Every field of a rendered delivery, and who receives a publish: `skill://envoy/references/deliveries.md`.
|
|
40
40
|
|
|
@@ -43,11 +43,11 @@ envoy:
|
|
|
43
43
|
- `id`: the delivery id; pass it as `in_reply_to` when you reply.
|
|
44
44
|
- `by`: the expiry deadline, when the sender set one.
|
|
45
45
|
- `urgency`: how soon the sender asks you to act: `low`, `med`, `high` or `blocking`. It is for you to read only: it does not change when a delivery reaches you, or its place among others.
|
|
46
|
-
- `expects_reply`: `
|
|
46
|
+
- `expects_reply`: what the sender wants back. `false` means nothing: do not reply, and do not acknowledge. `optional` means a reply is welcome and nothing waits on it. `required` means the sender waits on your answer.
|
|
47
47
|
- `re`: the delivery this one replies to. A Dispatch frame names it as a `dispatch://` ref: the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a document ask) or the message (`dispatch://KEY/message/<id>`), never the quoted text. On an ask, `dispatch.question` holds the start of the question's text; `dispatch read` gives the whole.
|
|
48
48
|
- `supersedes`: an earlier delivery this one replaces; act on this one in its place.
|
|
49
|
-
- `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes.
|
|
50
|
-
- `reply_role`: the role-publish reply call, when the sender holds a role.
|
|
49
|
+
- `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes. It is absent when `expects_reply` is `false`: a delivery that wants nothing back offers no way to send something. Where you answer one anyway, the card still names what you need — a Dispatch frame names its comment, message or ask, and an agent's delivery names its `id` and its sender.
|
|
50
|
+
- `reply_role`: the role-publish reply call, when the sender holds a role. It is absent under `false` for the same reason.
|
|
51
51
|
- `summary`: the one-line source summary.
|
|
52
52
|
- `message`: the complete payload; it can hold several paragraphs.
|
|
53
53
|
- `note`: the payload names another session; never use that quoted id as the recipient.
|
|
@@ -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"
|
|
@@ -476,7 +478,9 @@ export function createChannelDelivery(input: {
|
|
|
476
478
|
dedupe_key: envelope?.dedupe_key,
|
|
477
479
|
urgency: envelope?.urgency,
|
|
478
480
|
from_session: envelope?.source_session,
|
|
479
|
-
|
|
481
|
+
// One vocabulary for the inbox: the inbound schema reads a frame old enough to carry
|
|
482
|
+
// the string "none" as `false`, and the metadata carries that one spelling.
|
|
483
|
+
expects_reply: envelope?.expects_reply?.toString(),
|
|
480
484
|
in_reply_to: envelope?.in_reply_to,
|
|
481
485
|
}),
|
|
482
486
|
},
|
|
@@ -1029,8 +1033,15 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1029
1033
|
if (relay?.sessionID === identity.id) relay.delivery.setFollowed(registeredTopics())
|
|
1030
1034
|
return {
|
|
1031
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.
|
|
1032
1039
|
unsubscribing: serialized(() =>
|
|
1033
|
-
options.client.unsubscribe({
|
|
1040
|
+
options.client.unsubscribe({
|
|
1041
|
+
sessionID: identity.id,
|
|
1042
|
+
topics,
|
|
1043
|
+
clientInstanceID: options.clientInstanceID,
|
|
1044
|
+
}),
|
|
1034
1045
|
),
|
|
1035
1046
|
}
|
|
1036
1047
|
}
|
|
@@ -1108,6 +1119,27 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1108
1119
|
takeDeliveryAnswer(sessionID, topics, asking, result)
|
|
1109
1120
|
}
|
|
1110
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
|
+
|
|
1111
1143
|
/**
|
|
1112
1144
|
* Take back the role persisted for `previousSessionID` (this id on `--resume`,
|
|
1113
1145
|
* the pre-`/clear` id on a handoff) with a soft claim naming that predecessor. The listener
|
|
@@ -1115,30 +1147,64 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1115
1147
|
* carries this process's own client instance id, or when either side carries none; a
|
|
1116
1148
|
* predecessor still live under another client instance keeps the role.
|
|
1117
1149
|
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
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
|
|
1121
1175
|
* predecessor demonstrably no longer holds it, so its record goes — the one path that removes
|
|
1122
1176
|
* a role record by hand.
|
|
1123
1177
|
*/
|
|
1124
|
-
const restoreRole = async (previousSessionID
|
|
1178
|
+
const restoreRole = async (previousSessionID: string): Promise<"settled" | "pending"> => {
|
|
1125
1179
|
const previousFile = roleStateFile(options.stateDirectory, previousSessionID)
|
|
1126
1180
|
const persisted = await readPersistedRole(previousFile)
|
|
1127
|
-
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
|
+
}
|
|
1128
1191
|
const claim = await options.client.setRole({
|
|
1129
1192
|
sessionID: identity.id,
|
|
1130
1193
|
role: persisted.role,
|
|
1131
1194
|
soft: true,
|
|
1132
1195
|
previousSessionID: persisted.session_id,
|
|
1196
|
+
clientInstanceID: options.clientInstanceID,
|
|
1133
1197
|
})
|
|
1134
1198
|
if (!claim.claimed) {
|
|
1199
|
+
if (claim.holder === identity.id) return "pending"
|
|
1135
1200
|
if (claim.holder !== persisted.session_id) await rm(previousFile, { force: true })
|
|
1136
|
-
return
|
|
1201
|
+
return "settled"
|
|
1137
1202
|
}
|
|
1138
1203
|
heldRole = persisted.role
|
|
1139
1204
|
const currentFile = roleStateFile(options.stateDirectory, identity.id)
|
|
1140
1205
|
if (previousFile !== currentFile) await rm(previousFile, { force: true })
|
|
1141
|
-
await
|
|
1206
|
+
await writeRoleRecord(currentFile, persisted.role)
|
|
1207
|
+
return "settled"
|
|
1142
1208
|
}
|
|
1143
1209
|
|
|
1144
1210
|
let regained: RoleRegainHook | undefined
|
|
@@ -1185,15 +1251,29 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1185
1251
|
}
|
|
1186
1252
|
}
|
|
1187
1253
|
|
|
1188
|
-
/**
|
|
1189
|
-
*
|
|
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
|
+
*/
|
|
1190
1272
|
const reassertRole = async (afterOutage: boolean): Promise<void> => {
|
|
1191
1273
|
if (heldRole === undefined) return
|
|
1192
1274
|
const role = heldRole
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
.then((info) => info.holder, undefinedOnNotFound)
|
|
1196
|
-
if (holder === identity.id) {
|
|
1275
|
+
const holding = await options.client.getRole(role).catch(undefinedOnNotFound)
|
|
1276
|
+
if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
|
|
1197
1277
|
await refreshRoleRecord()
|
|
1198
1278
|
if (afterOutage) fireRegained(role, "reregistered")
|
|
1199
1279
|
return
|
|
@@ -1202,40 +1282,41 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1202
1282
|
sessionID: identity.id,
|
|
1203
1283
|
role,
|
|
1204
1284
|
soft: true,
|
|
1285
|
+
clientInstanceID: options.clientInstanceID,
|
|
1205
1286
|
})
|
|
1206
1287
|
if (!result.claimed) {
|
|
1207
|
-
|
|
1208
|
-
|
|
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()
|
|
1209
1294
|
return
|
|
1210
1295
|
}
|
|
1211
1296
|
fireRegained(role, "reclaimed")
|
|
1212
1297
|
}
|
|
1213
1298
|
|
|
1214
|
-
/**
|
|
1215
|
-
* The id a handoff moved away from, until its role has been taken back; a
|
|
1216
|
-
* handoff whose registration failed leaves it for the next heartbeat.
|
|
1217
|
-
*/
|
|
1218
|
-
let roleTransferFrom: string | undefined
|
|
1219
1299
|
/** Whether a failure to take back the queued role has been reported since the last success. */
|
|
1220
1300
|
let roleTransferFailureReported = false
|
|
1221
1301
|
/**
|
|
1222
1302
|
* Best-effort, unlike `syncRegisteredInterests` behind it on the heartbeat. A failed transfer
|
|
1223
|
-
* keeps its own retry, `roleTransferFrom`, which clears only once `restoreRole`
|
|
1224
|
-
* throw would add nothing to that and would end the tick
|
|
1225
|
-
* on every tick for as long as the failure lasts — a
|
|
1226
|
-
* subscriptions and a registry-side unsubscribe that
|
|
1227
|
-
* session works without its role meanwhile; it does not
|
|
1228
|
-
* 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.
|
|
1229
1310
|
*/
|
|
1230
1311
|
const transferRole = async (): Promise<void> => {
|
|
1231
1312
|
const from = roleTransferFrom
|
|
1232
1313
|
if (from === undefined) return
|
|
1233
1314
|
try {
|
|
1234
|
-
await restoreRole(from)
|
|
1235
|
-
roleTransferFrom = undefined
|
|
1315
|
+
roleTransferFrom = (await restoreRole(from)) === "pending" ? from : undefined
|
|
1236
1316
|
roleTransferFailureReported = false
|
|
1237
1317
|
} catch (error) {
|
|
1238
|
-
//
|
|
1318
|
+
// `roleTransferFrom` is untouched, so the next heartbeat asks again.
|
|
1319
|
+
// Said once per failure run.
|
|
1239
1320
|
if (!roleTransferFailureReported) {
|
|
1240
1321
|
roleTransferFailureReported = true
|
|
1241
1322
|
process.stderr.write(
|
|
@@ -1388,7 +1469,11 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1388
1469
|
topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic),
|
|
1389
1470
|
)
|
|
1390
1471
|
if (drifted.length === 0) return
|
|
1391
|
-
await options.client.unsubscribe({
|
|
1472
|
+
await options.client.unsubscribe({
|
|
1473
|
+
sessionID: identity.id,
|
|
1474
|
+
topics: drifted,
|
|
1475
|
+
clientInstanceID: options.clientInstanceID,
|
|
1476
|
+
})
|
|
1392
1477
|
}
|
|
1393
1478
|
|
|
1394
1479
|
// One tick at a time, and at most one pending behind it. `serialized` already
|
|
@@ -1523,22 +1608,31 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1523
1608
|
// failure but one: a listener answer that is not JSON or misses the schema, an unreadable
|
|
1524
1609
|
// role file, any transient error. The one is a role file whose bytes cannot be parsed, which
|
|
1525
1610
|
// only `readPersistedRole` can tell apart: no later read of the same bytes succeeds, so it is
|
|
1526
|
-
// not queued, and it costs this session its role and nothing more.
|
|
1527
|
-
|
|
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) {
|
|
1528
1622
|
if (error instanceof UnparseableRoleRecordError) {
|
|
1529
1623
|
process.stderr.write(
|
|
1530
1624
|
`envoy-channel: ${messageFor(error)}; the session starts without a role\n`,
|
|
1531
1625
|
)
|
|
1532
|
-
|
|
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
|
+
)
|
|
1533
1634
|
}
|
|
1534
|
-
|
|
1535
|
-
// This is the first failure of the run `transferRole` reports, so it says nothing more
|
|
1536
|
-
// until the retry has succeeded once.
|
|
1537
|
-
roleTransferFailureReported = true
|
|
1538
|
-
process.stderr.write(
|
|
1539
|
-
`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries\n`,
|
|
1540
|
-
)
|
|
1541
|
-
})
|
|
1635
|
+
}
|
|
1542
1636
|
})
|
|
1543
1637
|
// The first sweep, once the listener knows what the session follows: what was stored while it
|
|
1544
1638
|
// was away (a resume included), or, for a new session, its cursors' start at the stream's end.
|
|
@@ -1635,10 +1729,7 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
|
|
|
1635
1729
|
},
|
|
1636
1730
|
async rememberRole(role) {
|
|
1637
1731
|
heldRole = role
|
|
1638
|
-
await
|
|
1639
|
-
session_id: identity.id,
|
|
1640
|
-
role,
|
|
1641
|
-
})
|
|
1732
|
+
await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role)
|
|
1642
1733
|
},
|
|
1643
1734
|
async shutdown() {
|
|
1644
1735
|
if (shuttingDown) return
|
|
@@ -1679,6 +1770,9 @@ export interface ChannelToolRuntime {
|
|
|
1679
1770
|
readonly identity: SessionIdentity
|
|
1680
1771
|
readonly client: EnvoyClient
|
|
1681
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
|
|
1682
1776
|
}
|
|
1683
1777
|
export async function executeEnvoyTool(
|
|
1684
1778
|
runtime: ChannelToolRuntime,
|
|
@@ -1766,7 +1860,14 @@ export async function executeEnvoyTool(
|
|
|
1766
1860
|
}
|
|
1767
1861
|
case EnvoyToolOperation.setRole: {
|
|
1768
1862
|
const args = parseArguments(spec, input)
|
|
1769
|
-
|
|
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
|
+
})
|
|
1770
1871
|
if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`)
|
|
1771
1872
|
await runtime.session.rememberRole(args.role)
|
|
1772
1873
|
return result.interest
|
|
@@ -2084,13 +2185,14 @@ export async function runEnvoyChannelServer(): Promise<void> {
|
|
|
2084
2185
|
...(configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}),
|
|
2085
2186
|
})
|
|
2086
2187
|
}
|
|
2087
|
-
runtime = { identity, client, session }
|
|
2188
|
+
runtime = { identity, client, session, clientInstanceID }
|
|
2088
2189
|
if (pane !== undefined && daemon !== undefined && !stopping) {
|
|
2089
2190
|
claim = startLegionClaim({
|
|
2090
2191
|
pane,
|
|
2091
2192
|
sessionID: identity.id,
|
|
2092
2193
|
daemon,
|
|
2093
2194
|
client,
|
|
2195
|
+
clientInstanceID,
|
|
2094
2196
|
session,
|
|
2095
2197
|
signal: booting.signal,
|
|
2096
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
|
|