@sjawhar/pi-legion-envoy 1.32.0 → 1.33.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/dist/envoy.js +17 -2
- package/dist/legion.js +89 -14
- package/dist/skills/legion-architect/SKILL.md +1 -1
- package/package.json +2 -2
package/dist/envoy.js
CHANGED
|
@@ -30308,6 +30308,19 @@ var stateRole = strictObject({
|
|
|
30308
30308
|
launchFailures: number2().int().nonnegative().optional(),
|
|
30309
30309
|
locator: stateLocator.optional()
|
|
30310
30310
|
});
|
|
30311
|
+
var stateQueuedWorkerIdentity = {
|
|
30312
|
+
roleToken: nonEmptyString,
|
|
30313
|
+
issue: nonEmptyString,
|
|
30314
|
+
role: nonEmptyString
|
|
30315
|
+
};
|
|
30316
|
+
var stateQueuedWorker = union([
|
|
30317
|
+
strictObject(stateQueuedWorkerIdentity),
|
|
30318
|
+
strictObject({
|
|
30319
|
+
...stateQueuedWorkerIdentity,
|
|
30320
|
+
kind: _enum2(["assignment", "catchup"]),
|
|
30321
|
+
queuedAt: nonEmptyString
|
|
30322
|
+
})
|
|
30323
|
+
]);
|
|
30311
30324
|
var LegionDaemonApi = {
|
|
30312
30325
|
State: {
|
|
30313
30326
|
response: strictObject({
|
|
@@ -30324,7 +30337,8 @@ var LegionDaemonApi = {
|
|
|
30324
30337
|
controllerLocator: stateLocator.optional(),
|
|
30325
30338
|
roles: record(string2(), stateRole),
|
|
30326
30339
|
controllerPendingNotices: number2().int().nonnegative(),
|
|
30327
|
-
pendingStatusWrites: array(nonEmptyString)
|
|
30340
|
+
pendingStatusWrites: array(nonEmptyString),
|
|
30341
|
+
workerAdmission: strictObject({ queue: array(stateQueuedWorker) })
|
|
30328
30342
|
})
|
|
30329
30343
|
},
|
|
30330
30344
|
ControllerReady: {
|
|
@@ -30411,7 +30425,8 @@ var LegionDaemonApi = {
|
|
|
30411
30425
|
request: architectCapability.extend({
|
|
30412
30426
|
issue: nonEmptyString,
|
|
30413
30427
|
role: legionRole,
|
|
30414
|
-
task: nonEmptyString
|
|
30428
|
+
task: nonEmptyString,
|
|
30429
|
+
requestId: uuid2()
|
|
30415
30430
|
}),
|
|
30416
30431
|
response: object({
|
|
30417
30432
|
status: _enum2(["spawned", "resumed", "queued"]),
|
package/dist/legion.js
CHANGED
|
@@ -16112,7 +16112,7 @@ var require_mod4 = __commonJS(function(exports) {
|
|
|
16112
16112
|
});
|
|
16113
16113
|
|
|
16114
16114
|
// extensions/legion.ts
|
|
16115
|
-
import { randomUUID as
|
|
16115
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
16116
16116
|
import fs from "fs";
|
|
16117
16117
|
import path4 from "path";
|
|
16118
16118
|
|
|
@@ -30308,6 +30308,19 @@ var stateRole = strictObject({
|
|
|
30308
30308
|
launchFailures: number2().int().nonnegative().optional(),
|
|
30309
30309
|
locator: stateLocator.optional()
|
|
30310
30310
|
});
|
|
30311
|
+
var stateQueuedWorkerIdentity = {
|
|
30312
|
+
roleToken: nonEmptyString,
|
|
30313
|
+
issue: nonEmptyString,
|
|
30314
|
+
role: nonEmptyString
|
|
30315
|
+
};
|
|
30316
|
+
var stateQueuedWorker = union([
|
|
30317
|
+
strictObject(stateQueuedWorkerIdentity),
|
|
30318
|
+
strictObject({
|
|
30319
|
+
...stateQueuedWorkerIdentity,
|
|
30320
|
+
kind: _enum2(["assignment", "catchup"]),
|
|
30321
|
+
queuedAt: nonEmptyString
|
|
30322
|
+
})
|
|
30323
|
+
]);
|
|
30311
30324
|
var LegionDaemonApi = {
|
|
30312
30325
|
State: {
|
|
30313
30326
|
response: strictObject({
|
|
@@ -30324,7 +30337,8 @@ var LegionDaemonApi = {
|
|
|
30324
30337
|
controllerLocator: stateLocator.optional(),
|
|
30325
30338
|
roles: record(string2(), stateRole),
|
|
30326
30339
|
controllerPendingNotices: number2().int().nonnegative(),
|
|
30327
|
-
pendingStatusWrites: array(nonEmptyString)
|
|
30340
|
+
pendingStatusWrites: array(nonEmptyString),
|
|
30341
|
+
workerAdmission: strictObject({ queue: array(stateQueuedWorker) })
|
|
30328
30342
|
})
|
|
30329
30343
|
},
|
|
30330
30344
|
ControllerReady: {
|
|
@@ -30411,7 +30425,8 @@ var LegionDaemonApi = {
|
|
|
30411
30425
|
request: architectCapability.extend({
|
|
30412
30426
|
issue: nonEmptyString,
|
|
30413
30427
|
role: legionRole,
|
|
30414
|
-
task: nonEmptyString
|
|
30428
|
+
task: nonEmptyString,
|
|
30429
|
+
requestId: uuid2()
|
|
30415
30430
|
}),
|
|
30416
30431
|
response: object({
|
|
30417
30432
|
status: _enum2(["spawned", "resumed", "queued"]),
|
|
@@ -30637,6 +30652,14 @@ function parseControlDirective(raw) {
|
|
|
30637
30652
|
}
|
|
30638
30653
|
|
|
30639
30654
|
// src/legion/daemon-client.ts
|
|
30655
|
+
function defaultSleep(ms) {
|
|
30656
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
30657
|
+
setTimeout(resolve, ms);
|
|
30658
|
+
return promise;
|
|
30659
|
+
}
|
|
30660
|
+
var SPAWN_WORKER_RETRY_DELAYS_MS = [2000, 5000];
|
|
30661
|
+
var SPAWN_WORKER_ATTEMPTS = SPAWN_WORKER_RETRY_DELAYS_MS.length + 1;
|
|
30662
|
+
|
|
30640
30663
|
class LegionDaemonApiError extends Error {
|
|
30641
30664
|
method;
|
|
30642
30665
|
path;
|
|
@@ -30650,6 +30673,30 @@ class LegionDaemonApiError extends Error {
|
|
|
30650
30673
|
this.responseBody = responseBody;
|
|
30651
30674
|
}
|
|
30652
30675
|
}
|
|
30676
|
+
|
|
30677
|
+
class LegionDaemonTransportError extends Error {
|
|
30678
|
+
path;
|
|
30679
|
+
requestId;
|
|
30680
|
+
attempts;
|
|
30681
|
+
constructor(path, requestId, attempts, cause) {
|
|
30682
|
+
super(`POST ${path} got no response in ${attempts} attempts (request ${requestId}): ${messageFor(cause)}. The daemon may have received the request; read legion state (workerAdmission.queue and the role's claim) before sending it again.`, { cause });
|
|
30683
|
+
this.path = path;
|
|
30684
|
+
this.requestId = requestId;
|
|
30685
|
+
this.attempts = attempts;
|
|
30686
|
+
this.name = "LegionDaemonTransportError";
|
|
30687
|
+
}
|
|
30688
|
+
}
|
|
30689
|
+
|
|
30690
|
+
class LegionDaemonResponseReadError extends Error {
|
|
30691
|
+
path;
|
|
30692
|
+
requestId;
|
|
30693
|
+
constructor(path, requestId, cause) {
|
|
30694
|
+
super(`POST ${path} response body could not be read (request ${requestId}): ${messageFor(cause)}. The daemon may have received the request; read legion state (workerAdmission.queue and the role's claim) before sending it again.`, { cause });
|
|
30695
|
+
this.path = path;
|
|
30696
|
+
this.requestId = requestId;
|
|
30697
|
+
this.name = "LegionDaemonResponseReadError";
|
|
30698
|
+
}
|
|
30699
|
+
}
|
|
30653
30700
|
function isInvalidSessionSecret(error) {
|
|
30654
30701
|
if (!(error instanceof LegionDaemonApiError) || error.status !== 403)
|
|
30655
30702
|
return false;
|
|
@@ -30668,19 +30715,42 @@ function sessionCapability(body) {
|
|
|
30668
30715
|
return { sessionId: candidate.sessionId, secret: candidate.secret };
|
|
30669
30716
|
}
|
|
30670
30717
|
var WORKER_SESSION_PATH = "/legion/v1/worker-session";
|
|
30671
|
-
function createLegionDaemonClient(baseUrl, fetchFn = fetch, recovery) {
|
|
30718
|
+
function createLegionDaemonClient(baseUrl, fetchFn = fetch, recovery, sleep = defaultSleep) {
|
|
30672
30719
|
const endpoint = baseUrl.replace(/\/+$/, "");
|
|
30673
|
-
const postOnce = async (path, body, schema) => {
|
|
30674
|
-
const response = await
|
|
30720
|
+
const postOnce = async (path, body, schema, fetchImpl = fetchFn, onResponseReadError) => {
|
|
30721
|
+
const response = await fetchImpl(`${endpoint}${path}`, {
|
|
30675
30722
|
method: "POST",
|
|
30676
30723
|
headers: { "Content-Type": "application/json" },
|
|
30677
30724
|
body: JSON.stringify(body)
|
|
30678
30725
|
});
|
|
30679
|
-
|
|
30726
|
+
let responseBody;
|
|
30727
|
+
try {
|
|
30728
|
+
responseBody = await response.text();
|
|
30729
|
+
} catch (error) {
|
|
30730
|
+
throw onResponseReadError?.(error) ?? error;
|
|
30731
|
+
}
|
|
30680
30732
|
if (!response.ok)
|
|
30681
30733
|
throw new LegionDaemonApiError("POST", path, response.status, responseBody);
|
|
30682
30734
|
return schema.parse(JSON.parse(responseBody));
|
|
30683
30735
|
};
|
|
30736
|
+
const transportRetryingFetch = (path, requestId) => {
|
|
30737
|
+
let transportFailures = 0;
|
|
30738
|
+
return async (input, init) => {
|
|
30739
|
+
for (;; ) {
|
|
30740
|
+
try {
|
|
30741
|
+
return await fetchFn(input, init);
|
|
30742
|
+
} catch (error) {
|
|
30743
|
+
transportFailures += 1;
|
|
30744
|
+
const delay = SPAWN_WORKER_RETRY_DELAYS_MS[transportFailures - 1];
|
|
30745
|
+
if (delay === undefined) {
|
|
30746
|
+
throw new LegionDaemonTransportError(path, requestId, transportFailures, error);
|
|
30747
|
+
}
|
|
30748
|
+
console.error(`[legion] POST ${path} attempt ${transportFailures}/${SPAWN_WORKER_ATTEMPTS} got no response (${messageFor(error)}); retrying request ${requestId} in ${delay} ms`);
|
|
30749
|
+
await sleep(delay);
|
|
30750
|
+
}
|
|
30751
|
+
}
|
|
30752
|
+
};
|
|
30753
|
+
};
|
|
30684
30754
|
const recoveries = new Map;
|
|
30685
30755
|
const secretAfterRefusal = async (active, sessionId, refusedSecret) => {
|
|
30686
30756
|
let record = recoveries.get(sessionId);
|
|
@@ -30705,16 +30775,16 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch, recovery) {
|
|
|
30705
30775
|
}
|
|
30706
30776
|
return (await record.inFlight).secret;
|
|
30707
30777
|
};
|
|
30708
|
-
const post = async (path, body, schema) => {
|
|
30778
|
+
const post = async (path, body, schema, fetchImpl = fetchFn, onResponseReadError) => {
|
|
30709
30779
|
try {
|
|
30710
|
-
return await postOnce(path, body, schema);
|
|
30780
|
+
return await postOnce(path, body, schema, fetchImpl, onResponseReadError);
|
|
30711
30781
|
} catch (error) {
|
|
30712
30782
|
const capability = sessionCapability(body);
|
|
30713
30783
|
if (path === WORKER_SESSION_PATH || recovery === undefined || capability === undefined || !isInvalidSessionSecret(error)) {
|
|
30714
30784
|
throw error;
|
|
30715
30785
|
}
|
|
30716
30786
|
const secret = await secretAfterRefusal(recovery, capability.sessionId, capability.secret);
|
|
30717
|
-
return await postOnce(path, { ...body, secret }, schema);
|
|
30787
|
+
return await postOnce(path, { ...body, secret }, schema, fetchImpl, onResponseReadError);
|
|
30718
30788
|
}
|
|
30719
30789
|
};
|
|
30720
30790
|
const get = async (path, schema) => {
|
|
@@ -30734,7 +30804,7 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch, recovery) {
|
|
|
30734
30804
|
processReady: (input) => noContent("/legion/v1/process/ready", input, LegionDaemonApi.ProcessReady.response),
|
|
30735
30805
|
workerStarted: (input) => post("/legion/v1/worker/started", input, LegionDaemonApi.WorkerStarted.response),
|
|
30736
30806
|
workerReady: (input) => noContent("/legion/v1/worker/ready", input, LegionDaemonApi.WorkerReady.response),
|
|
30737
|
-
spawnWorker: (input) => post("/legion/v1/worker/spawn", input, LegionDaemonApi.SpawnWorker.response),
|
|
30807
|
+
spawnWorker: (input) => post("/legion/v1/worker/spawn", input, LegionDaemonApi.SpawnWorker.response, transportRetryingFetch("/legion/v1/worker/spawn", input.requestId), (error) => new LegionDaemonResponseReadError("/legion/v1/worker/spawn", input.requestId, error)),
|
|
30738
30808
|
releaseWave: (input) => post("/legion/v1/waves/release", input, LegionDaemonApi.WaveRelease.response),
|
|
30739
30809
|
provisioningCredential: (input) => post("/legion/v1/provisioning-credential", input, LegionDaemonApi.ProvisioningCredential.response),
|
|
30740
30810
|
escalate: (input) => noContent("/legion/v1/escalate", input, LegionDaemonApi.Escalate.response),
|
|
@@ -30813,6 +30883,9 @@ commit_trailers = '"Omp-Session: ${id}"'
|
|
|
30813
30883
|
}
|
|
30814
30884
|
}
|
|
30815
30885
|
|
|
30886
|
+
// src/legion/tools.ts
|
|
30887
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
30888
|
+
|
|
30816
30889
|
// src/tool-result.ts
|
|
30817
30890
|
function toolSuccess(text, details = {}) {
|
|
30818
30891
|
return { content: [{ type: "text", text }], details };
|
|
@@ -30868,7 +30941,7 @@ function createLegionTool(deps) {
|
|
|
30868
30941
|
return {
|
|
30869
30942
|
name: "legion",
|
|
30870
30943
|
label: "legion",
|
|
30871
|
-
description: "Perform a Legion lifecycle write through the Legion daemon. " + "register_gate records the root spec document a human must approve: `artifactId` is the " + "document id (a UUID) and `version` the version number, both copied from the `artifact` and " + "`version` fields of dispatch_request_approval's result \u2014 never the slug or file name you " + "passed to that tool. " + `spawn_worker's response "status" means: "spawned" \u2014 a fresh pane just opened and is ` + 'running now; "resumed" \u2014 an existing worker was prompted directly over its live socket ' + "and its turn started, or (if its boot has not confirmed yet) its task was recorded to " + 'deliver once that boot completes; "queued" \u2014 the task was recorded and this role will ' + "start on its own: either the running-worker cap is full, or the live worker acknowledged " + "the task without starting a turn and the daemon is retrying it. Never re-spawn a role " + 'after "resumed" or "queued" \u2014 wait for the worker-started notification instead.',
|
|
30944
|
+
description: "Perform a Legion lifecycle write through the Legion daemon. " + "register_gate records the root spec document a human must approve: `artifactId` is the " + "document id (a UUID) and `version` the version number, both copied from the `artifact` and " + "`version` fields of dispatch_request_approval's result \u2014 never the slug or file name you " + "passed to that tool. " + `spawn_worker's response "status" means: "spawned" \u2014 a fresh pane just opened and is ` + 'running now; "resumed" \u2014 an existing worker was prompted directly over its live socket ' + "and its turn started, or (if its boot has not confirmed yet) its task was recorded to " + 'deliver once that boot completes; "queued" \u2014 the task was recorded and this role will ' + "start on its own: either the running-worker cap is full, or the live worker acknowledged " + "the task without starting a turn and the daemon is retrying it. Never re-spawn a role " + 'after "resumed" or "queued" \u2014 wait for the worker-started notification instead. A call ' + 'that fails with "got no response in 3 attempts" was retried by the plugin with one ' + "request id; the daemon may still have received it \u2014 read legion state " + "(workerAdmission.queue, and the role in roles) before sending it again. A spawn_worker " + "identical to the task already queued for the role changes nothing and is not announced again.",
|
|
30872
30945
|
defaultInactive: true,
|
|
30873
30946
|
parameters: legionToolSchema(pi),
|
|
30874
30947
|
execute: async (_id, parameters, _signal, _onUpdate, context) => {
|
|
@@ -30958,13 +31031,15 @@ function createLegionTool(deps) {
|
|
|
30958
31031
|
if (typeof role !== "string" || !LEGION_ROLES.includes(role)) {
|
|
30959
31032
|
throw new Error("spawn_worker requires a valid Legion role");
|
|
30960
31033
|
}
|
|
31034
|
+
const requestId = randomUUID2();
|
|
30961
31035
|
return jsonSuccess(await daemon.spawnWorker({
|
|
30962
31036
|
tree: architect.tree,
|
|
30963
31037
|
sessionId,
|
|
30964
31038
|
secret: architect.secret,
|
|
30965
31039
|
issue: stringInput("issue"),
|
|
30966
31040
|
role,
|
|
30967
|
-
task: stringInput("task")
|
|
31041
|
+
task: stringInput("task"),
|
|
31042
|
+
requestId
|
|
30968
31043
|
}));
|
|
30969
31044
|
}
|
|
30970
31045
|
default:
|
|
@@ -34707,7 +34782,7 @@ function isToolDeviceInvocation(toolCall) {
|
|
|
34707
34782
|
return toolCall.toolName === "write" && typeof toolCall.input.path === "string" && toolCall.input.path.startsWith("xd://");
|
|
34708
34783
|
}
|
|
34709
34784
|
function legionExtension(pi) {
|
|
34710
|
-
const instance =
|
|
34785
|
+
const instance = randomUUID3().slice(0, 8);
|
|
34711
34786
|
logger2.debug("extension instance loaded", { extension: import.meta.url, instance });
|
|
34712
34787
|
globalThis[LEGION_LOADED_MARKER] = import.meta.url;
|
|
34713
34788
|
const defaults = envoyDefaultsFromEnvironment(process.env);
|
|
@@ -301,7 +301,7 @@ active phase worker.
|
|
|
301
301
|
| `design-approved` | Payload `{type:"design-approved"}`. A human approved the root spec document at its current version; the gate is open. Proceed to section 2. |
|
|
302
302
|
| `design-changes-requested` | Payload `{type:"design-changes-requested", version, reason, author?}`. A human asked for changes to the root spec at `version`, for `reason`. Revise the spec, call `dispatch_request_approval` again, and stay parked; the gate is closed. |
|
|
303
303
|
| `phase-complete` | Payload `{type:"phase-complete", issue, role, summary}`. May arrive live or via `catchup-overseer`'s `phaseCompletions`. Read the committed handoff for that phase, then spawn the next phase's owner, or `spawn_worker` on the same role again to resume it with corrections if the handoff shows unresolved gaps. A `reviewer` completion whose GitHub review is `CHANGES_REQUESTED` (the daemon returns the issue's Dispatch status to `in_progress` for this, on the reviewer's completion and again when you spawn the corrective implementer unless the daemon already knows the issue is `in_progress`) means `spawn_worker` the **implementer** again with the review findings — thread URLs and blocking items — as its task, then route back through tester and reviewer in order; never `spawn_worker` the reviewer directly off this wake and never proceed to retro on this verdict. A reviewer completion with an `APPROVED` review proceeds to retro (step 5). A `reviewer` completion after a conflict-forced rebase whose review body names an unchanged fingerprint is a confirmation, not a round: if retro already completed, `spawn_worker` the merger; otherwise resume the step you were on. An `implementer` completion that follows the merge is its production report: read the record on the pull request and the issue, then run step 7 — the issue is already at `retro`, the daemon writes no status for this completion, and you set `done` yourself. A `tester` completion whose handoff carries `implementerProof.verdict: "rejected"`, or a failure naming the production-like proof, goes back to the **implementer** with that finding — never forward to the reviewer, and never by supplying the proof from another role. A worker that reports no surface reaches the changed path gets a child issue in this tree (infrastructure, tooling, or a skill) and a resume once it lands; that report is never a reason to advance the phase. |
|
|
304
|
-
| `worker-queued` | Payload `{type:"worker-queued", issue, role}`. This role's task is queued for promotion — either the deployment's worker cap is full, or the live worker acknowledged the task without starting a turn and the daemon is retrying it (counted; the worker is replaced after three such failures, still with the same task). Do not respawn or retry — wait for `worker-started`. |
|
|
304
|
+
| `worker-queued` | Payload `{type:"worker-queued", issue, role}`. This role's task is queued for promotion — either the deployment's worker cap is full, or the live worker acknowledged the task without starting a turn and the daemon is retrying it (counted; the worker is replaced after three such failures, still with the same task). Do not respawn or retry — wait for `worker-started`. `legion state` shows the queue (`workerAdmission.queue`: role token, issue, role, kind, and the time the task was first queued — never the task text); read it before re-sending. A `spawn_worker` identical to the queued task changes nothing and is not announced again. Different text replaces the queued task silently in the same FIFO slot and retains its original queue time. A `spawn_worker` that fails with "got no response in 3 attempts" was already retried by the plugin under one request id and may still have reached the daemon: read the queue and the role's claim in `legion state` before sending it again. |
|
|
305
305
|
| `worker-started` | Payload `{type:"worker-started", issue, role}`. A previously queued role has been promoted and is now running. Treat it exactly as a normal spawn: resume tracking that role's live session. |
|
|
306
306
|
| `pr-ready` | Verify the live PR head, green status, and review state. Continue the review/retro/merger order only for that current head. |
|
|
307
307
|
| `pr-review` | Payload `{type:"pr-review", state, author, body}`. Delivered to whichever role is currently active for the issue, falling back to you when no worker phase is active. Follows the same verdict rule as a reviewer's `phase-complete`: `state: "changes_requested"` sends the implementer back in with the review findings, then tester, then reviewer — never the reviewer again and never retro; that `spawn_worker` returns the issue to `in_progress` on its own (the daemon writes it for a corrective implementer whenever the PR's latest recorded review is changes requested, a human's after approval included), so you set nothing by hand; `state: "approved"` proceeds toward retro (step 5) once the step 6 integration/merge-gate conditions are met. `state: "approved"` on a rebased head whose body names an unchanged fingerprint is that confirmation: proceed to retro if it has not run, otherwise to the merger — never to a second retro or test round. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/pi-legion-envoy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"omp": {
|
|
6
6
|
"extensions": [
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
"legion": {
|
|
15
|
-
"daemonApiVersion":
|
|
15
|
+
"daemonApiVersion": 4
|
|
16
16
|
},
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|