@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 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 randomUUID2 } from "crypto";
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 fetchFn(`${endpoint}${path}`, {
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
- const responseBody = await response.text();
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 = randomUUID2().slice(0, 8);
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.32.0",
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": 3
15
+ "daemonApiVersion": 4
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",