@sjawhar/pi-legion 10.29.2 → 11.0.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/legion.js CHANGED
@@ -14445,6 +14445,7 @@ var SEARCH_QUERY_HINT = "search with a short phrase of a few words, not a passag
14445
14445
  var SEARCH_KIND_DEPTH = 100;
14446
14446
  var PROJECT_KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
14447
14447
  var PROJECT_KEY_RULE = "an uppercase letter, then 1 to 9 uppercase letters or digits, such as CORE";
14448
+ var ARCHITECTURE_SOURCE_REPO_PATTERN = /^[A-Za-z0-9-]+\/(?!\.\.?$)[A-Za-z0-9._-]+$/;
14448
14449
  var ISSUE_STATUSES = [
14449
14450
  "triage",
14450
14451
  "icebox",
@@ -14810,7 +14811,7 @@ var dispatchToolSpecs = [
14810
14811
  {
14811
14812
  name: "dispatch_issues",
14812
14813
  example: { project: "PROJ", limit: 250, offset: 250 },
14813
- description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never.",
14814
+ description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, progress (its spec's task-list items and " + "its direct children, each done of total, as `tasks 3/7 \xB7 children 2/5`, each only when it has " + "any), and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch search remains the keyword surface. " + "Dispatch pages the list: limit sets the page size (default " + `${DEFAULT_ISSUE_PAGE_LIMIT}, max ${MAX_ISSUE_PAGE_LIMIT}) and offset selects where it starts ` + "(default 0), and the answer names how many issues match, so repeat with the next offset to " + "walk every matching issue. A walk is exact only while the list does not change: an issue " + "that enters or leaves what the filters match, or whose status or rank changes, between two " + "pages shifts rows across a page boundary, so one issue can come back twice and another never. " + "all takes the whole filtered list in one read instead, for a caller that must not miss or " + "repeat a row while other writers change the list, and especially for one whose own writes " + "move issues out of what it filtered on.",
14814
14815
  arguments: (z2) => ({
14815
14816
  project: z2.string().describe("Project key to list issues from."),
14816
14817
  status: z2.enum(ISSUE_STATUSES).describe("Optional lifecycle status filter.").optional(),
@@ -14820,7 +14821,8 @@ var dispatchToolSpecs = [
14820
14821
  updated_since: z2.string().describe("Optional RFC3339 timestamp; only issues updated at or after it.").optional(),
14821
14822
  route_status: z2.enum(ISSUE_ROUTE_STATUSES).describe("Optional: only open issues whose route is in this state. live: a running session holds " + "the role or is the routed session. no_holder: nobody running holds the role, or the " + "session is not running, right now. unknown: the Envoy listener did not answer.").optional(),
14822
14823
  limit: z2.number({ int: true, min: 1, max: MAX_ISSUE_PAGE_LIMIT }).describe(`Maximum rows, 1-${MAX_ISSUE_PAGE_LIMIT}; default ${DEFAULT_ISSUE_PAGE_LIMIT}.`).optional(),
14823
- offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional()
14824
+ offset: z2.number({ int: true, min: 0 }).describe("Rows to skip before the page; nonnegative integer; default 0.").optional(),
14825
+ all: z2.boolean().describe("Take every matching issue in one unpaged read, so no row is missed or repeated while " + "the list changes. Refused with limit or offset.").optional()
14824
14826
  })
14825
14827
  },
14826
14828
  {
@@ -14842,11 +14844,19 @@ var dispatchToolSpecs = [
14842
14844
  },
14843
14845
  {
14844
14846
  name: "dispatch_architecture_sync",
14845
- example: { project: "CORE" },
14846
- description: "Import a project's architecture model from its configured source repository now, instead of " + "waiting for the server's five-minute schedule. Returns the imported commit and component " + "count, or the recorded error when the model was rejected (the previous model stays up). " + "The source itself is configured by a human in Settings; 404 SOURCE_NOT_FOUND without one.",
14847
+ example: { project: "CORE", repo: "acme/infra" },
14848
+ description: "Import a project's architecture model from its source repositories now, instead of waiting " + "for the server's five-minute schedule. A project may have several sources, one per " + "repository, and its model is every source's components together. Without repo every " + "source imports and each answers on its own line; with repo only that source does. Each " + "line names the imported commit, or the recorded error when that source's model was " + "rejected (its previous components stay up, and every other source is untouched). Sources " + "are saved in Settings or with PUT /api/v1/projects/{key}/architecture-sources/{owner}/{repo}; " + "404 SOURCE_NOT_FOUND when the project has none, or none for that repository.",
14847
14849
  arguments: (z2) => ({
14848
- project: z2.string().describe("Project key whose architecture source to sync, such as CORE.")
14850
+ project: z2.string().describe("Project key whose architecture sources to sync, such as CORE."),
14851
+ repo: z2.string().describe("One source repository of the project to sync alone, owner/name; omit it to sync every source.").optional()
14849
14852
  }),
14853
+ validation: {
14854
+ check: (value) => {
14855
+ const { repo } = value;
14856
+ return typeof repo !== "string" || ARCHITECTURE_SOURCE_REPO_PATTERN.test(repo);
14857
+ },
14858
+ message: "repo is not a repository: give it as owner/name, such as acme/infra."
14859
+ },
14850
14860
  strict: true
14851
14861
  },
14852
14862
  {
@@ -15063,6 +15073,9 @@ function asErrorShape(value) {
15063
15073
  function isJson(response) {
15064
15074
  return response.headers.get("content-type")?.includes("application/json") ?? false;
15065
15075
  }
15076
+ function isNoRoute(error48) {
15077
+ return error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "NOT_FOUND" && error48.message.startsWith("no route for ");
15078
+ }
15066
15079
  var ISSUE_EVENTS_PAGE_MAX = 200;
15067
15080
  var DISPATCH_TOOL_DEADLINE_MS = 60000;
15068
15081
  function requestSignal(signal) {
@@ -15090,6 +15103,18 @@ function whyNotAPage(answer) {
15090
15103
  }
15091
15104
  return `${what}. Retrying will not help: the same request gets the same answer until that ` + "Dispatch is upgraded or fixed.";
15092
15105
  }
15106
+ function whyNotAnArray(answer) {
15107
+ if (typeof answer === "string")
15108
+ return `text that is not JSON, ${GATEWAY_PAGE}, so a retry may succeed`;
15109
+ if (answer === null)
15110
+ return "null";
15111
+ if (typeof answer !== "object")
15112
+ return `a ${typeof answer}`;
15113
+ if ("issues" in answer && Array.isArray(answer.issues)) {
15114
+ return "a page, so this Dispatch pages a request that carries no limit and no offset";
15115
+ }
15116
+ return "an object that is not the listing";
15117
+ }
15093
15118
  var GATEWAY_PAGE = "which looks like a proxy or gateway page rather than Dispatch's own answer";
15094
15119
  function gatewayAdvice(method, status) {
15095
15120
  if (!transientStatus(status)) {
@@ -15165,6 +15190,14 @@ class DispatchClient {
15165
15190
  return answer;
15166
15191
  throw new Error(`GET ${this.#url(path2, query)} asked for a page ({issues, total, limit, offset}) and got ` + refusal);
15167
15192
  }
15193
+ async listAllIssues(options) {
15194
+ const path2 = ["api", "v1", "issues"];
15195
+ const query = { ...options };
15196
+ const answer = await this.#json("GET", path2, undefined, query);
15197
+ if (Array.isArray(answer))
15198
+ return answer;
15199
+ throw new Error(`GET ${this.#url(path2, query)} asked for the unpaged listing and got ${whyNotAnArray(answer)}.`);
15200
+ }
15168
15201
  async listProjectArtifacts(project, unlinked = false) {
15169
15202
  return this.#json("GET", ["api", "v1", "projects", project, "artifacts"], undefined, unlinked ? { unlinked: "true" } : undefined);
15170
15203
  }
@@ -15253,19 +15286,69 @@ class DispatchClient {
15253
15286
  async listProjects() {
15254
15287
  return this.#json("GET", ["api", "v1", "projects"]);
15255
15288
  }
15256
- async syncArchitectureSource(project) {
15257
- return this.#json("POST", ["api", "v1", "projects", project, "architecture-source", "sync"]);
15258
- }
15259
- async getArchitectureSource(project) {
15289
+ async listArchitectureSources(project) {
15290
+ try {
15291
+ return await this.#json("GET", ["api", "v1", "projects", project, "architecture-sources"]);
15292
+ } catch (error48) {
15293
+ if (!isNoRoute(error48))
15294
+ throw error48;
15295
+ }
15260
15296
  try {
15261
- return await this.#json("GET", ["api", "v1", "projects", project, "architecture-source"]);
15297
+ const source = await this.#json("GET", [
15298
+ "api",
15299
+ "v1",
15300
+ "projects",
15301
+ project,
15302
+ "architecture-source"
15303
+ ]);
15304
+ return source === null ? [] : [source];
15262
15305
  } catch (error48) {
15263
15306
  if (error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "SOURCE_NOT_FOUND") {
15264
- return null;
15307
+ return [];
15265
15308
  }
15266
15309
  throw error48;
15267
15310
  }
15268
15311
  }
15312
+ async syncArchitectureSources(project) {
15313
+ try {
15314
+ return await this.#json("POST", [
15315
+ "api",
15316
+ "v1",
15317
+ "projects",
15318
+ project,
15319
+ "architecture-sources",
15320
+ "sync"
15321
+ ]);
15322
+ } catch (error48) {
15323
+ if (!isNoRoute(error48))
15324
+ throw error48;
15325
+ }
15326
+ const source = await this.#json("POST", [
15327
+ "api",
15328
+ "v1",
15329
+ "projects",
15330
+ project,
15331
+ "architecture-source",
15332
+ "sync"
15333
+ ]);
15334
+ return [source];
15335
+ }
15336
+ async syncArchitectureSource(project, repo2) {
15337
+ const [owner, name, ...rest] = repo2.split("/");
15338
+ if (!owner || !name || rest.length > 0) {
15339
+ throw new Error(`repository must be owner/name, got ${JSON.stringify(repo2)}`);
15340
+ }
15341
+ return this.#json("POST", [
15342
+ "api",
15343
+ "v1",
15344
+ "projects",
15345
+ project,
15346
+ "architecture-sources",
15347
+ owner,
15348
+ name,
15349
+ "sync"
15350
+ ]);
15351
+ }
15269
15352
  async resolveAsk(id, input) {
15270
15353
  return this.#json("POST", ["api", "v1", "asks", id, "resolve"], input);
15271
15354
  }
@@ -15817,10 +15900,15 @@ var daemonInfo = exports_external.strictObject({
15817
15900
  firstBootAt: timestamp,
15818
15901
  startedAt: timestamp
15819
15902
  });
15903
+ var legionBlockedIssue = exports_external.strictObject({
15904
+ issue: issueKey,
15905
+ blockedBy: exports_external.array(issueKey)
15906
+ });
15820
15907
  var legionAdmission = exports_external.strictObject({
15821
15908
  cap: exports_external.number().int().nonnegative(),
15822
15909
  active: exports_external.array(nonEmptyString),
15823
15910
  waiting: exports_external.array(nonEmptyString),
15911
+ blocked: exports_external.array(legionBlockedIssue),
15824
15912
  free: exports_external.number().int().nonnegative().optional()
15825
15913
  });
15826
15914
  var legionControllerLocator = exports_external.strictObject({
@@ -15968,7 +16056,9 @@ var LegionHandoffCompleteResponse = exports_external.strictObject({
15968
16056
  note: nonEmptyString.optional()
15969
16057
  });
15970
16058
  var LegionWaveReleaseResponse = exports_external.strictObject({
15971
- released: exports_external.array(nonEmptyString)
16059
+ released: exports_external.array(nonEmptyString),
16060
+ started: exports_external.array(issueKey),
16061
+ held: exports_external.array(legionBlockedIssue)
15972
16062
  });
15973
16063
  var LegionGrantRequest = exports_external.strictObject({
15974
16064
  sessionId: nonEmptyString,
@@ -16956,7 +17046,7 @@ async function bootLegionClaim(host2) {
16956
17046
  // package.json
16957
17047
  var package_default = {
16958
17048
  name: "@sjawhar/pi-legion",
16959
- version: "10.29.2",
17049
+ version: "11.0.0",
16960
17050
  license: "Apache-2.0",
16961
17051
  type: "module",
16962
17052
  omp: {
@@ -16968,7 +17058,7 @@ var package_default = {
16968
17058
  ]
16969
17059
  },
16970
17060
  legion: {
16971
- daemonApiVersion: 36
17061
+ daemonApiVersion: 39
16972
17062
  },
16973
17063
  repository: {
16974
17064
  type: "git",
@@ -32,8 +32,9 @@ You own one issue tree from its first decision to the merge that closes it. The
32
32
 
33
33
  ## 2. Children in flight
34
34
 
35
- - Release a child only after each child it depends on has closed, because Legion does not order children: `legion({ op: "release_children", issues: ["LEGION-41", "LEGION-42"] })`.
35
+ - Set a child's dependencies in one call naming every issue it waits on: `dispatch issue-update --issue <child> --blocked-by <a> --blocked-by <b>`, or `--blocked-by` on `dispatch issue` at creation. The call replaces the child's whole list, so read its existing links first (`dispatch read`). Release it with the rest: `legion({ op: "release_children", issues: ["LEGION-41", "LEGION-42"] })`. Legion holds a released child that waits on an issue not `done` and starts it itself at its last blocker's close. A child that builds on another repository's merged change is not held this way: release it as `skill://legion-architect/references/decomposition.md` "Across repositories" says.
36
36
  - On each `child-closed`, read the child's handoff. Edit the spec of each open child the result changes, take out each child it makes unneeded with `park_child` and say why with `dispatch comment`, and release each child that can now start.
37
+ - Before you park a child for good (one made unneeded, or parked so its tree can finish), set the `--blocked-by` list of each sibling that waits on it again without it (one call with the rest of its list, or `--clear-blocked-by` when it was the only one), or park that sibling too: a parked child is stopped, not done, and its dependents stay held with no notice. A park followed by `rerun_child` keeps its dependents' links: they wait through the rerun and start when the rerun's merge closes the child.
37
38
  - A released child runs in your tree's admission slot, never its own, and never becomes a root while your tree is live.
38
39
 
39
40
  ## 3. Children complete
@@ -120,8 +120,9 @@ The merge closes the issue: when GitHub reports the pull request merged while th
120
120
  `pr-merged` notice names what it waits for and none follows as they depart, so read the pull
121
121
  request on GitHub.
122
122
  - A child's close reaches you as `child-closed`. The daemon retires five of its six claims and
123
- suspends its implementer, session and volume kept, because it still owes the production check
124
- (`skill://legion-worker/references/merge-gate.md`, *The production check after the close*).
123
+ leaves the implementer running, because it still owes the production check. When the issue's
124
+ pod and Sandbox go is in `skill://legion-worker/references/merge-gate.md`, *The production check
125
+ after the close*.
125
126
  - A root's close writes `done`, frees the tree's admission slot and lingers the tree for
126
127
  `linger_hours` with every member suspended. The linger's end, the tree's last step, waits until
127
128
  every member's open production check has closed.
@@ -135,9 +136,11 @@ the looks, what woke the last one and when the next is due).
135
136
 
136
137
  When a person closes a root and it stays `done` through its linger, each child still in the
137
138
  workflow is handed to Legion as a root of its own: `todo` with the `legion` label, its phase, gate,
138
- phase records and pull request carried into a new generation, and that phase's role started. The
139
- child stays its own tree; reopening the old root does not take it back. Park a child with
140
- `park_child` before then when its work should stop with the root.
139
+ phase records and pull request carried into a new generation, and that phase's role started. A
140
+ child that never started but still waits on an unfinished issue is handed over too, carrying no
141
+ run: it waits in the line until its blockers are done, and its architect starts as a fresh root
142
+ and registers its own spec. The child stays its own tree; reopening the old root does not take it
143
+ back. Park a child with `park_child` before then when its work should stop with the root.
141
144
 
142
145
  If you are that child's architect:
143
146
 
@@ -19,7 +19,9 @@ Put every adopted child into the first wave and release it with
19
19
  `legion({ op: "release_children", issues: ["LEGION-41", "LEGION-42"] })`. The daemon runs nothing on
20
20
  a child until it is released. Once it is released, the daemon moves it to `todo` and, while the
21
21
  root's design gate is open, starts its phases itself. It starts no sub-architect, and you start no
22
- worker.
22
+ worker. A release is accepted whole whatever the `--blocked-by` links say: its answer names
23
+ `started` and `held` (each held child with the issues it waits on) beside `released`, and a held
24
+ child waits in your tree until Legion starts it.
23
25
 
24
26
  ## Creating children
25
27
 
@@ -56,22 +58,28 @@ creation, because the daemon fixes it when it records the child.
56
58
  Release a child that depends on another repository's change only after that change's pull request
57
59
  merges, because the child's work must build on the merged change. Name the merged commit in its
58
60
  task (`gh api repos/<owner>/<repo>/pulls/<n> --jq .merge_commit_sha`); for a repository that pins the
59
- other one by commit (a lock file or dependency pin), the pin is that commit. Read the other
60
- repositories with your gh's read-only access (`-R owner/repo`).
61
+ other one by commit (a lock file or dependency pin), the pin is that commit. Do not hold such a
62
+ child with `--blocked-by` instead, because Legion would start it at the blocker's close, before you
63
+ name the merged commit. Read the other repositories with your gh's read-only access
64
+ (`-R owner/repo`).
61
65
 
62
66
  ## Architecture components
63
67
 
64
- A project's architecture source is the repository whose `.dispatch/architecture/` files Dispatch
65
- imports as its components (`skill://dispatch`, "Architecture components").
68
+ A project's architecture sources are the repositories whose `.dispatch/architecture/` files
69
+ Dispatch imports as its components (`skill://dispatch`, "Architecture components").
66
70
 
67
71
  - **The components exist:** attach the root before you decompose, so every child inherits it:
68
72
  `dispatch issue-update --issue <root> --components-json '{"mode":"explicit","ids":[…]}'`.
69
73
  - **They do not exist yet (bootstrap):** in the root's first PR (the implement worker pushes it),
70
74
  write `.dispatch/architecture/<id>.md` files for the planned components — front matter `title`,
71
- `parent`, `depends_on`, `external`; no `paths` yet. The importer reads the source branch's head,
72
- so leave the root on `inherit` until that PR has merged: an attach before then answers
75
+ `parent`, `depends_on`, `external`; no `paths` yet. A project may have several sources, one per
76
+ repository, and its model is all of their components together: the file goes in the branch of
77
+ the source whose repository holds it, `parent` and `depends_on` name components of that same
78
+ source only, and its id must be one no other source of the project uses (a repeated id fails that
79
+ source's import, naming the repository that has it). The importer reads the source branch's
80
+ head, so leave the root on `inherit` until that PR has merged: an attach before then answers
73
81
  `400 COMPONENTS_INPUT`, since the component does not exist. After the merge, run
74
- `dispatch architecture-sync --project <KEY>` and attach the root; children created before then
75
- inherit it.
82
+ `dispatch architecture-sync --project <KEY>` (every source; `--repo <owner/name>` syncs that one
83
+ alone) and attach the root; children created before then inherit it.
76
84
  - Give a child its own `--components-json` only when it changes a narrower set, and
77
85
  `{"mode":"none","reason":"…"}` when it changes no code or file a component describes.
@@ -19,14 +19,14 @@ and 7 (a conflict, the hand-over, the close) is in
19
19
  | Wake | Procedure |
20
20
  | --- | --- |
21
21
  | `carried-gate-opened` | Only a root whose catch-up said `carried` gets it. Its `version` and `reason`: a human approved the old root's spec document at `version`, which opened the gate this root carried; the daemon has released any READY it refused and started any child waiting on it. It is not `design-approved`: do not proceed to section 2 or release a wave because of it, and start nothing. |
22
- | `child-status` | A child of your tree left the workflow or re-entered it; the notice's reason names the child and its new status. `todo` (a human's move, your `release_children`, or your `rerun_child`) means the child runs again under your tree from planning, and the daemon starts it; you start nothing. `backlog`, `icebox` or `triage` (a human's move, or your `park_child`) means the daemon has suspended the child's workers, and it advances no further until it is set back to `todo`. What the rest of the tree does is your decision. |
23
- | `child-closed` | Read the child completion and remaining open children. Re-scope open work, park obsolete work with `park_child`, and release an appropriate next wave with `release_children`. When a worker waits on this child for a missing surface (see `phase-finished`), tell it to continue with `envoy_publish` to its role topic. The last child's close is not the end-game (section 3). |
22
+ | `child-status` | A child of your tree left the workflow or re-entered it; the reason names the child and its new status. `todo` (a human's move, your `release_children`, or your `rerun_child`) means it runs again under your tree from planning, and the daemon starts it; you start nothing. A child held for a blocker starts the same way once nothing holds it, and the reason, naming it and its root, says what cleared the hold: its last blocker's close, naming the issues it waited on, now done, or a person or you taking its blocked-by links off; you start nothing for it either. `backlog`, `icebox` or `triage` (a human's move, or your `park_child`) means the daemon has suspended its workers, and it advances no further until it is set back to `todo`. What the rest of the tree does is your decision. |
23
+ | `child-closed` | Read the child completion and remaining open children. Re-scope open work, park obsolete work for good with `park_child` (first take its link off each sibling that waits on it, or park that sibling too, as section 2 says), and release an appropriate next wave with `release_children`. When a worker waits on this child for a missing surface (see `phase-finished`), tell it to continue with `envoy_publish` to its role topic. The last child's close is not the end-game (section 3). |
24
24
  | `child-release-stalled` | Its `reason` names a child your `release_children` released while Dispatch already showed it `todo`, and that child's parent, whose release has not reached the daemon after three tries. Read the parent's record with the `legion` tool's `read_record` (`issue` the parent); run `release_children` for the child again once the parent is recorded in your tree, and tell the controller if it never is. |
25
25
  | `child-not-recorded` | Its `reason` names a child your tree did not record and why: its repository resolves to none (neither it nor an ancestor names one, and the project has no default), or to one not mapped to the project, or to one the daemon leaves out for a missing GitHub App. The daemon said why on the child too. Set the child's repository to one of the project's (`dispatch issue-update --repo owner/name`), then release it again with `release_children`; tell the controller when the repository it needs is not served. |
26
26
  | `design-approved` | Its `version` is the approved one. A human approved the root spec document at its current version; the gate is open. Proceed to section 2 — except on a root whose catch-up said `handed_over`: its workers already run their phases, so start nothing; the approval releases any READY the gate refused. One whose `version` is the version a human's own answer to a decision block wrote, after an earlier approval (Dispatch carried that approval to it, "After approval" in `skill://legion-architect/references/design-gate.md`), opens nothing new: carry on, and release no wave because of it. |
27
27
  | `design-changes-requested` | Its `version` and `reason`. A human asked for changes to the root spec at `version`, for `reason`. Revise the spec as the reason asks and request approval again as "The armed gate" in `skill://legion-architect/references/design-gate.md` says; end your turn; the gate is closed. |
28
28
  | `phase-finished` | The daemon has already moved the issue to its next phase by its fixed table and started that phase's role; you start nothing. Read the committed handoff for the finishing phase; if it shows unresolved gaps, tell the role now working the issue (`envoy_publish` to its role topic). A `planner` notice that names a departure from the spec's design defers to the full condition in "After approval" of `skill://legion-architect/references/design-gate.md`: when the approved Summary, Acceptance, scope and settled decisions still hold, the plan is the record; otherwise change the root spec and request approval again as that section says. A `reviewer` notice whose GitHub review is `CHANGES_REQUESTED` needs nothing from you: the daemon has returned the issue to `implementing` (Dispatch `in_progress`) and started the **implementer**, whose correction goes through the tester and the reviewer again, never straight to retro. A `reviewer` notice with an `APPROVED` review means the issue is in `accepting` and the acceptance is yours (section 6): read the evidence, then `sign_off` or send it back with `request_backward_move`. A `tester` notice with `verdict: "fail"` — its handoff carries `implementerProof.verdict: "rejected"`, or a failure naming the production-like proof — has gone back to the **implementer** by the daemon's table; never supply the proof from another role. A worker that reports no surface reaches the changed path sends that report instead of completing its phase, so the daemon starts nothing more on that issue and the worker stays idle in its session, not suspended: file a child issue in this tree to build the surface (infrastructure, tooling, or a skill), and when that child's `child-closed` arrives, tell the waiting worker to continue with `envoy_publish` to its role topic. That report is never a reason to advance the phase. |
29
- | `pr-blocked` | Its `reason` names the pull request and the `max_fix_attempts` it reached, or reads `Issue reached review_round_cap=<N>.` (the end of this row). The count is of heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. The notice moves nothing, and no later CI red within the attempt that ran the count out (the counted head, and every head pushed on top of it until a push lands on a head CI judged green or on a settled head whose base branch requires no check and no workflow, the tester's red tests and handoff onto the held red included) sends the work back from `testing`, `reviewing`, `accepting`, `retro` or `merging` either: the issue stays in its phase until you move it, or a worker's own decision does (a tester's `fail`, a reviewer's request for changes). A head pushed onto green CI, or onto a settled head whose base branch requires no check and no workflow, leaves that attempt, so its red still comes as a `checks-red`. In `implementing`, give the implementer the failing checks (`envoy_publish` to its role topic). In any later phase a worker holds, tell that worker (`envoy_publish` to its role topic) to move the issue back to `implementing` with `request_backward_move`, naming the failing checks, and the daemon starts the implementer; or file a corrective child. In `accepting`, where no worker holds a phase, the move is yours: `request_backward_move` with `issue`, naming the failing checks. In `awaiting_merge`, where no worker holds a phase and a backward move is refused, open a `dispatch ask` naming the failing checks for the human who merges. Do not treat the blocked PR as final. A `review_round_cap` reason is the implementer's round count instead: every move back to an earlier phase counts one, and the notice comes again with each round past the cap. It moves nothing either, since the round that reached the cap has already started its worker. Tell the rounds apart by what announced each: a tester's fail by its `phase-finished` with `verdict: "fail"`; a request for changes by the reviewer's `phase-finished` over a `CHANGES_REQUESTED` review; the daemon's own move by a `checks-red` notice, whose reason names a conflict (`the head conflicts with <base>`), the required checks that failed (`CI is red at <head>`), or the merge queue's failed merge and its failing run (`the merge queue took pull request #<n> out at READY's head …`); a worker's `request_backward_move` by a `phase-finished` from the role that asked, whose reason reaches only the task of the worker the move started, so ask that worker; your own send-back from `accepting` by the reason you gave. Judge the cap by the rounds that are not conflicts. Conflict returns need nothing from you beyond the next round. Tester fails, requests for changes and CI-red returns (a `checks-red` notice is raised for CI only when a required check fails on code the branch pushed, or when the merge queue fails the merge) are the rounds the cap is for. Each `review_round_cap` notice is a decision you make in the turn it wakes, never one left for the next round to make: read what every counted round found, choose one of the three below, act on it in the same turn, and post it as a `dispatch comment` on the issue naming the rounds you read and the choice. **Narrow**: the change itself is sound but each round finds more around it; tell the implementer and the reviewer (`envoy_publish` to each one's role topic) which acceptance lines the next round answers; a correctness finding is still fixed in this pull request, and only naming, duplication or wording cleanup moves to the `Fast-follow:` line. **Split**: part of the work cannot settle in this pull request. On a child, file that part as a child of this issue's parent, never of this one (a parent waits in `accepting` for every child, and the split part usually builds on this pull request's unmerged code): `dispatch issue --project <project> --parent <parent> --title '<outcome>' --blocked-by <this issue> --spec-file -`, released with `release_children` once this issue closes. On the root, narrow it to what settles here and open a `dispatch ask` for the rest (`skill://legion-worker/references/findings.md`): a person decides whether that becomes an issue. Tell the implementer and the reviewer that this pull request no longer carries it. **Accept the rounds**: each round found less than the one before it, and the last found no defect in the change itself (a stale line, a comment that states an old default); let the loop run and say why in the comment. A later notice is a new decision: accept again only when its round found less than the round you last accepted, and otherwise narrow or split. A finding that changes what the spec accepts is a decision block in the root spec ("After approval" in `skill://legion-architect/references/design-gate.md`), and `dispatch ask` is only for a decision that needs a human's authority; neither replaces the choice. Judge a worker's backward move by the reason that worker gives. A move you ordered for a conflict found before `awaiting_merge` ("A conflicting head" in `skill://legion-architect/references/acceptance.md`) is a conflict return. The merger's move back to `reviewing` for a READY refusal (`READY_UNREVIEWED_CHANGE`, or `READY_NOT_APPROVED` when its task names no approved head), and the reviewer's no-fix request for changes that follows it so the tester runs the head, are not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
29
+ | `pr-blocked` | Its `reason` names the pull request and the `max_fix_attempts` it reached, or reads `Issue reached review_round_cap=<N>.` (the end of this row). The count is of heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. The notice moves nothing, and no later CI red within the attempt that ran the count out (the counted head, and every head pushed on top of it until a push lands on a head CI judged green or on a settled head whose base branch requires no check and no workflow, the tester's red tests and handoff onto the held red included) sends the work back from `testing`, `reviewing`, `accepting`, `retro` or `merging` either: the issue stays in its phase until you move it, or a worker's own decision does (a tester's `fail`, a reviewer's request for changes). A head pushed onto green CI, or onto a settled head whose base branch requires no check and no workflow, leaves that attempt, so its red still comes as a `checks-red`. In `implementing`, give the implementer the failing checks (`envoy_publish` to its role topic). In any later phase a worker holds, tell that worker (`envoy_publish` to its role topic) to move the issue back to `implementing` with `request_backward_move`, naming the failing checks, and the daemon starts the implementer; or file a corrective child. In `accepting`, where no worker holds a phase, the move is yours: `request_backward_move` with `issue`, naming the failing checks. In `awaiting_merge`, where no worker holds a phase and a backward move is refused, open a `dispatch ask` naming the failing checks for the human who merges. Do not treat the blocked PR as final. A `review_round_cap` reason is the implementer's round count instead: every move back to an earlier phase counts one, and the notice comes again with each round past the cap. It moves nothing either, since the round that reached the cap has already started its worker. Tell the rounds apart by what announced each: a tester's fail by its `phase-finished` with `verdict: "fail"`; a request for changes by the reviewer's `phase-finished` over a `CHANGES_REQUESTED` review; the daemon's own move by a `checks-red` notice, whose reason names a conflict (`the head conflicts with <base>`), the required checks that failed (`CI is red at <head>`), or the merge queue's failed merge and its failing run (`the merge queue took pull request #<n> out at READY's head …`); a worker's `request_backward_move` by a `phase-finished` from the role that asked, whose reason reaches only the task of the worker the move started, so ask that worker; your own send-back from `accepting` by the reason you gave. Judge the cap by the rounds that are not conflicts. Conflict returns need nothing from you beyond the next round. Tester fails, requests for changes and CI-red returns (a `checks-red` notice is raised for CI only when a required check fails on code the branch pushed, or when the merge queue fails the merge) are the rounds the cap is for. Each `review_round_cap` notice is a decision you make in the turn it wakes, never one left for the next round to make: read what every counted round found, choose one of the three below, act on it in the same turn, and post it as a `dispatch comment` on the issue naming the rounds you read and the choice. **Narrow**: the change itself is sound but each round finds more around it; tell the implementer and the reviewer (`envoy_publish` to each one's role topic) which acceptance lines the next round answers; a correctness finding is still fixed in this pull request, and only naming, duplication or wording cleanup moves to the `Fast-follow:` line. **Split**: part of the work cannot settle in this pull request. On a child, file that part as a child of this issue's parent, never of this one (a parent waits in `accepting` for every child, and the split part usually builds on this pull request's unmerged code): `dispatch issue --project <project> --parent <parent> --title '<outcome>' --blocked-by <this issue> --spec-file -`. Release it now with `release_children`: Legion holds it on that `--blocked-by` link until this issue closes, and starts it then. On the root, narrow it to what settles here and open a `dispatch ask` for the rest (`skill://legion-worker/references/findings.md`): a person decides whether that becomes an issue. Tell the implementer and the reviewer that this pull request no longer carries it. **Accept the rounds**: each round found less than the one before it, and the last found no defect in the change itself (a stale line, a comment that states an old default); let the loop run and say why in the comment. A later notice is a new decision: accept again only when its round found less than the round you last accepted, and otherwise narrow or split. A finding that changes what the spec accepts is a decision block in the root spec ("After approval" in `skill://legion-architect/references/design-gate.md`), and `dispatch ask` is only for a decision that needs a human's authority; neither replaces the choice. Judge a worker's backward move by the reason that worker gives. A move you ordered for a conflict found before `awaiting_merge` ("A conflicting head" in `skill://legion-architect/references/acceptance.md`) is a conflict return. The merger's move back to `reviewing` for a READY refusal (`READY_UNREVIEWED_CHANGE`, or `READY_NOT_APPROVED` when its task names no approved head), and the reviewer's no-fix request for changes that follows it so the tester runs the head, are not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
30
30
  | `pr-merged` | The pull request merged and the issue did not close; its `reason` says why. Merged before the issue reached `awaiting_merge`: the workflow runs on, and the merge closes the issue the moment it gets there, your acceptance and retro first. Merged while the rest of the tree still runs: the reason names the pull request and every issue the close waits for, and the root waits in `awaiting_merge`, slot and workers kept, until the last of them is done or parked ("The close" in `skill://legion-architect/references/acceptance.md`). Either way you start nothing and no further notice follows. |
31
31
  | `pr-closed-unmerged` | Decide from current scope whether the work is reopened, started over (`park_child` then `rerun_child`, for a child), or ended with a reason. Delegate the repository action to the responsible phase worker and keep ownership. |
32
32
  | `merge-refused` | GitHub refused the daemon's submission of READY's merge in a way a retry would meet again, so it submits that merge no further: ask a person with `dispatch ask` to fix the repository setting or App permission and merge at READY's head (`skill://legion-architect/references/merge-notices.md#merge-refused`). |
@@ -91,7 +91,9 @@ A free slot is no reason to take an issue: a labelled root in `todo` takes the n
91
91
  `admission.waiting` without you, and a parked issue (status `backlog` or `icebox`) means "do not
92
92
  run now". Move an issue out of `backlog` or `icebox` only on a request or a plan ruling that names
93
93
  it, and cite that request. The free slots are `admission.cap` minus the roots in
94
- `admission.active` and `admission.waiting`. Every move to `todo` you make is one of these:
94
+ `admission.active` and the roots in `admission.waiting` that are not in `admission.blocked`, since
95
+ a blocked root takes no slot until its blockers are done. Every move to `todo` you make is one of
96
+ these:
95
97
 
96
98
  - a root a person handed over, at triage;
97
99
  - an issue a person asks you to start;
@@ -7,7 +7,8 @@ waits, or neither. Read its key against `admission` and its tree's root.
7
7
  | What `read_state` shows | What it means | What you do |
8
8
  |---|---|---|
9
9
  | The key is in `admission.active` | A root holding a slot: its tree runs, `admitted` until its architect starts the first phase | Nothing, beyond [trees waiting on a root claim](skill://legion-controller/SKILL.md#trees-waiting-on-a-root-claim) |
10
- | The key is in `admission.waiting` | A root handed to Legion in `todo`, queued in Dispatch rank order for the next free slot | Nothing: it takes the next free slot by itself |
10
+ | The key is in `admission.waiting` and not in `admission.blocked` | A root handed to Legion in `todo`, queued in Dispatch rank order for the next free slot | Nothing: it takes the next free slot by itself |
11
+ | The key is in `admission.waiting` and in `admission.blocked` | A root in the line that waits on the Dispatch issues `admission.blocked` names: admission passes it over, and it takes no slot, until every one of them is `done` | Nothing: the wait ends when each blocker reaches `done` (a person's close, or the blocker's own merge) or its link is taken off (by a person or the tree's architect). Name the wait in the day's report |
11
12
  | `phase: admitted`, in neither list, and Dispatch shows a `child_of` parent | A child: it runs or waits with its tree, the root its `child_of` chain reaches. Under a root whose `phase` is `done` it waits with it | Leave it to its tree. It runs again when its root does, or as a root of its own once it is `todo` with the `legion` label |
12
13
  | `phase: admitted`, `status: todo`, in neither list, no parent | A root a person took out of the line by taking its label off | Nothing: the label puts it back in the line, and a person's label is their decision |
13
14
  | `phase: done` while `status` is `in_progress`, `testing`, `needs_review` or `retro` | Someone other than Legion moved it to a running tree's status while Legion was not running it. A root that waited for a slot is taken out of the workflow at that move, since no slot would ever reach it, and the daemon sends the `unslotted` wake; a parked child stays parked. Legion starts nothing for either | Re-admit nothing yourself: `dispatch read` shows who works it now. Name it in your summary; `todo` with the `legion` label queues it again |
@@ -61,6 +61,9 @@ every issue by its key and title, every pull request by its URL.
61
61
  - **Running.** Each root in `admission.active` with its `issues.<KEY>.phase`, and the roots in
62
62
  `admission.waiting`. Name as waiting on a claim each root that this turn's "Trees waiting on a
63
63
  root claim" check found, with the holder its `Claimed by:` line names.
64
+ - **Waiting on Dispatch links.** Each root and child in `admission.blocked`, with the issues it
65
+ waits on. The wait ends when each blocker reaches `done` (a person's close, or the blocker's own
66
+ merge) or its link is taken off (by a person or the tree's architect), so take nothing for them.
64
67
  - **Free slots.** Counted as the skill's "Free slots" says; each stays free until a root in `todo`
65
68
  with the `legion` label takes it.
66
69
  - **Open capabilities.** Each row of `read_state`'s `capabilities` whose `status` is `open`: its
@@ -25,7 +25,7 @@ root took it. Name the free slots; take nothing.
25
25
  daemon waits half a minute and folds that window's events into one wake. Take nothing: it is not
26
26
  Legion's.
27
27
 
28
- ## `tick on <PROJECT>` (`{kind: "tick", openCapabilities?}`)
28
+ ## `tick on <PROJECT>` (`{kind: "tick", openCapabilities?, blocked?}`)
29
29
 
30
30
  The daemon's periodic wake, a minute after it starts and then every
31
31
  `controller_wake_interval_seconds` (an hour by default), whatever the slots. Run the skill's every-turn
@@ -41,6 +41,13 @@ has no `configLine`. The next daily report names each row still open; take no ot
41
41
  because the operator closes or decides a deployment row, and the operator or the issue's architect
42
42
  fixes a live row's session.
43
43
 
44
+ `blocked`, when present, is `[{issue, blockedBy}, …]`: every root and child Legion is waiting to
45
+ start behind an unfinished Dispatch issue, each with the issues it waits on. `read_state` shows the
46
+ same list under `admission.blocked`, and `legion state` prints it as `blocked on Dispatch links:`.
47
+ The wait ends when each blocker reaches `done` (a person's close, or the blocker's own merge) or its
48
+ link is taken off (by a person or the tree's architect): name the waits in the day's report and
49
+ take nothing for them.
50
+
44
51
  ## `unslotted on <KEY>` (`{kind: "unslotted", reason}`)
45
52
 
46
53
  A root that waited for a slot was moved from `todo` to the status `reason` names, by a person or by
@@ -11,7 +11,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
11
11
 
12
12
  ## Every phase, in this order
13
13
 
14
- 1. **Read first.** Read the issue, then each committed handoff before your phase, with `read` from `$LEGION_WORKSPACE/.legion/<issue>/`, in order: `plan.json`, `implement.json`, `test.json`, `review.json`. Where one disagrees with your memory or a transcript, the file wins: it is the copy that survived. At each later assignment read them again, your own included, since other phases may have committed since. After a `Your workspace was recreated…` message, read `.legion/<issue>/workspace-recovered.json` too, once.
14
+ 1. **Read first.** Read the issue, then each committed handoff before your phase, with `read` from `$LEGION_WORKSPACE/.legion/<issue>/`, in order: `plan.json`, `implement.json`, `test.json`, `review.json`. Where one disagrees with your memory or a transcript, the file wins: it is the copy that survived. At each later assignment read them again, your own included, since other phases may have committed since. After a `Your workspace was recreated…` message, anything unpushed is gone, and no file in the workspace says so.
15
15
  2. **Commit only your phase's work**, with `jj -R "$LEGION_WORKSPACE" split -m "<message>" <paths…>`, each commit holding only your phase's paths, so the next phase sees what you changed.
16
16
  3. **Put back what you copied in.** Before you write the handoff, run `jj -R "$LEGION_WORKSPACE" status` and remove or restore every file you copied or fetched in that is not your phase's work, such as another branch's test rig. With jj the working copy is the commit: the next role's first commit builds on what you left, and its push ships it.
17
17
  4. **Write the handoff** your role prompt names. Each `proof` entry, the implementer's or the tester's, is an object of six non-empty strings: `criterion` (the acceptance line it proves), `surface` (where a user reaches the change), `command`, `observed`, `headSha` (the commit it ran at) and `negativeControl` (a deliberately broken input and the refusal seen); `skill://legion-worker/references/pr-body.md#what-a-proof-is` defines each. To add to a handoff already on disk, `read` it, edit it, and `write` it whole with `completed` set to now.
@@ -251,16 +251,17 @@ reviewing until you finish.
251
251
  topic, and file no issue. It names this issue, the pull request, the merge commit, what you
252
252
  drove and what failed. Name the ask (`dispatch://<REPORT KEY>/ask/<id>`) in the `Production:`
253
253
  line and in the summary.
254
- - **Nothing waits in the session for a deploy.** A `pending` ends the look: the daemon suspends
255
- your claim with its session and the issue's volume kept, the pod goes, and the daemon wakes the
256
- check (the pod comes back and your session resumes with a new check task) on Dispatch's
254
+ - **Nothing waits in the session for a deploy.** A `pending` ends the look. Where no wake is
255
+ queued yet, the daemon suspends your claim with its session kept, and the issue's Sandbox goes
256
+ with its pod and disk; where a wake arrived while the look was open, the next look starts at once
257
+ on the same pod. Either way the daemon wakes the check (after a suspension a new pod comes back
258
+ on a fresh disk, and your session resumes with a new check task) on Dispatch's
257
259
  `issue.deployed` event for the pull request; when the project's `legion.yaml` names the
258
260
  repository's deploy workflow (`projects.<KEY>.deploys`), at each completed run of that workflow
259
261
  on the default branch whose named step (such as `Apply`) succeeded after the merge, once per such
260
262
  job; or, for `sjawhar/legion`, on a Legion daemon's boot on a build containing the merge, on
261
263
  Dispatch's `server.started` naming such a build, or on the build the daemon read from Dispatch at
262
- its boot. A wake while a look is open is kept, and a `pending` then starts the next look at once.
263
- The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
264
+ its boot. The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
264
265
  `the deploy workflow's run: <repo> <workflow> run <id> attempt <n>, job <job name>: <step> succeeded at <t>`,
265
266
  `the Legion daemon's boot on build <sha>`, `Dispatch's start on build <sha>`,
266
267
  `Dispatch's build <sha>`), and its `Production:` line names it too, so the pull request carries
@@ -279,5 +280,6 @@ reviewing until you finish.
279
280
  on a closed issue (`ISSUE_CLOSED`), so the deployment instructions' "on the pull request and the
280
281
  issue" is the pull request for a closed issue, and a defect reaches Dispatch as the ask above.
281
282
  The architect accepted the issue before the merge and is asked nothing after it. `pass` or
282
- `fail` closes the check: your claim is retired and, for a child, the issue's Sandbox and volume
283
- are released.
283
+ `fail` closes the check: your claim is retired and the issue's Sandbox goes with its pod and
284
+ disk, for a root as for a child. Neither ending retires or suspends anything of an issue back in
285
+ the workflow (a root reopened inside its linger): its claim and pod are the new run's.
@@ -31,7 +31,7 @@ which you have; those need no runner.
31
31
 
32
32
  ## Why a sixth is an image change, not an install
33
33
 
34
- Your container is an unprivileged user with no writable system prefix: `/usr/local/bin` and `/opt`
34
+ Your role runs as an unprivileged user with no writable system prefix: `/usr/local/bin` and `/opt`
35
35
  are read-only, `/home` is read-only too, and `apt-get` cannot take dpkg's lock even to download.
36
36
  `$HOME` — `/home/legion`, the one writable directory under that read-only `/home` — is yours to
37
37
  write.
@@ -48,7 +48,7 @@ extraction prefix under `$HOME` with `LD_LIBRARY_PATH` set, and `psql` did not
48
48
  extraction prefix is on. What rules such an attempt out is not the unpacking: it is the next
49
49
  section's test ("The test: where it lands, and what it leaves resolving"), and an extraction under
50
50
  `$HOME` fails that test's first condition whatever you scope it to, because `/home/legion` is the
51
- container's own filesystem rather than the issue's volume — the placement `roleEnvironment`
51
+ pod's disk rather than the issue's volume — the placement `roleEnvironment`
52
52
  (`packages/daemon/internal/runtime/sandbox/manifest.go`) exists to move writes off. The sanctioned
53
53
  extraction is the harness's: onto the issue's volume, and exported around its own commands and no
54
54
  further (the Dispatch e2e stack, below, is the worked case). For everything the five do not cover,
@@ -68,7 +68,7 @@ three artefacts in this repository are what draw the line — not a claim about
68
68
  `uv` and `uvx`, `bun` with its `bunx` link, and the Go toolchain, and the `toolchain` capability
69
69
  row (`packages/daemon/internal/capabilities/capabilities.go`) grants them by name. A runner put
70
70
  in the image deliberately and granted by a capability row is not a route around a withheld one.
71
- - **What the pod configures for those runners to write.** A role container's environment relocates
71
+ - **What the pod configures for those runners to write.** A role's environment relocates
72
72
  `UV_CACHE_DIR` and `UV_PYTHON_INSTALL_DIR` (`podkind.go`) and `BUN_INSTALL_CACHE_DIR`, `GOCACHE`
73
73
  and `GOMODCACHE` (`roleEnvironment`, `packages/daemon/internal/runtime/sandbox/manifest.go`)
74
74
  onto the issue's volume, shared by the issue's roles. Legion provisions the caches a pinned run
@@ -86,13 +86,13 @@ for it*.
86
86
 
87
87
  An install of your own — an interpreter, a browser, a second toolchain, an extraction prefix — is
88
88
  on the sanctioned side when both of these hold: what it writes lands on the issue's own volume,
89
- under `/legion`, which outlives the pod and is shared by the issue's roles — whether through a
90
- variable the runtime sets for you there or under a path on that volume your harness names
91
- itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under `/legion/workspaces`) and
92
- the runtime's own directories (`/legion/repos`, `/legion/feed`, `/legion/sessions`); and after
93
- the command exits nothing else resolves differently. The workspace is excluded because its jj
94
- working copy tracks every new file under jj's `snapshot.max-new-file-size` (1 MiB in a role
95
- container), so a root written there is added to the change every role shares, and a larger new
89
+ under `/legion`, which outlives a role's relaunch in its pod and is shared by the issue's roles —
90
+ whether through a variable the runtime sets for you there or under a path on that volume your
91
+ harness names itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under
92
+ `/legion/workspaces`) and the runtime's own directories (`/legion/repos`, `/legion/feed`); and
93
+ after the command exits nothing else resolves differently. The workspace is excluded because its jj
94
+ working copy tracks every new file under jj's `snapshot.max-new-file-size` (1 MiB for a role), so
95
+ a root written there is added to the change every role shares, and a larger new
96
96
  file is left untracked, every jj command in the workspace then warning `Refused to snapshot some
97
97
  files`. The one write into it that is sanctioned is a repository's own dependency install into
98
98
  its ignored directories, below. The variables the runtime sets on the volume, where a tool's own
@@ -100,8 +100,9 @@ cache already goes, are
100
100
  `XDG_CACHE_HOME` (`/legion/scratch/cache`), `XDG_DATA_HOME`, `XDG_STATE_HOME`, `TMPDIR`, `GOCACHE`,
101
101
  `GOMODCACHE`, `BUN_INSTALL_CACHE_DIR`, `UV_CACHE_DIR` (`/legion/uv/cache`) and
102
102
  `UV_PYTHON_INSTALL_DIR` (`/legion/uv/python/<issue>`). (`XDG_CONFIG_HOME` is set too and is not
103
- one of them: it is the pod's shared in-memory volume.) `$HOME` and `/tmp` are not on the volume:
104
- both are the container's own filesystem.
103
+ one of them: it is your role's own config home, a copy of the pod's made for your role on the
104
+ pod's disk, which no other role reads.) `$HOME` and `/tmp` are not on the volume:
105
+ both are your role's own, on the pod's disk.
105
106
 
106
107
  It is the withheld capability when either condition fails — anything that writes into a
107
108
  system prefix, and anything that leaves a toolchain, shim or binary which a later launch or
@@ -122,7 +123,7 @@ What each shape leaves resolving:
122
123
  all. `uv sync` or `bun install` in your workspace materialises what that project already
123
124
  declares, into that project's own directory, which a process reaches by asking for that project
124
125
  and no other way — and the runtime puts `UV_PYTHON_INSTALL_DIR` and the workspace itself on the
125
- issue's volume exactly so a later pod reuses it.
126
+ issue's volume exactly so every role of the issue's pod reuses it.
126
127
  - An extraction prefix: whatever you leave resolving through it, which is yours to decide. Unpack
127
128
  Debian packages under `$HOME`, export `LD_LIBRARY_PATH` in the shell you go on working in, and
128
129
  you change what every later command in that shell — and every subagent that inherits the
@@ -147,8 +148,8 @@ toolchain — is where the first condition does the work. `uv run --with pyyaml=
147
148
  passes both: the wheel lands in `UV_CACHE_DIR`, under `wheels-v6/pypi/pyyaml/6.0.2-…`, and
148
149
  `UV_PYTHON_INSTALL_DIR` is never created, because the image's own `python3` served the run. The
149
150
  interpreter is the other run: a project whose `.python-version` names an interpreter uv must fetch
150
- puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where the issue's next pod finds it, while the
151
- cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
151
+ puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where every role of the issue's pod finds it, while
152
+ the cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
152
153
  repository's own Dispatch suite installs (`playwright install`, `packages/dispatch`'s
153
154
  `e2e:install`) — it passes as it stands, with nothing pointed anywhere: playwright-core resolves
154
155
  its browser registry under `XDG_CACHE_HOME`, which the runtime has already put on the issue's
@@ -158,27 +159,32 @@ home — worth reading before an install, for the two things that follow from sh
158
159
  `PLAYWRIGHT_BROWSERS_PATH` elsewhere is what costs you: that shared registry, and the lock that
159
160
  makes a second role installing at the same time wait rather than race. The condition is for a tool
160
161
  whose cache the runtime does not already place — Puppeteer is one, resolving its browser cache to
161
- `join(homedir(), '.cache', 'puppeteer')` and so to the container's own filesystem whatever
162
+ `join(homedir(), '.cache', 'puppeteer')` and so to your role's own home on the pod's disk whatever
162
163
  `XDG_CACHE_HOME` says. For that one, point the tool's own cache variable (`PUPPETEER_CACHE_DIR`
163
164
  here) at a directory under one of the paths above yourself, since an unset one leaves the download
164
165
  wherever the tool defaults, which is nowhere this runtime chose.
165
166
 
166
167
  Where it lands is what you pay. Under one of those variables it is on the issue's volume: it
167
- outlives the pod, and every role of the issue shares it — under every one of those variables but
168
- `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, "the one directory of the issue's
169
- scratch that is the role's own rather than the issue's" (`roleEnvironment`), because Oh My Pi
170
- names its browser broker's lock from the state root and an abstract socket name is one name across
171
- every container of the pod — and a later pod of the issue reuses it. Under `$HOME` —
172
- `/home/legion`, which no volume is mounted on — it is the container's own filesystem: one copy per
173
- role, per pod, gone when the role relaunches, and charged to the container's ephemeral-storage
174
- limit, which is per role and smaller than the volume. Moving exactly these writes off that
168
+ outlives a role's relaunch in its pod, and every role of the issue is told the same path — under
169
+ every one of those variables but
170
+ `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, a directory named for your role
171
+ (`roleEnvironment`) because Oh My Pi names its browser broker's lock from the state root and an
172
+ abstract socket name is one name across every role of the pod, which share its network. It is not
173
+ private: every role of the issue can write it, as it can every path above — though it does not
174
+ outlive the pod: the issue's disk is the pod's own and is deleted with it, so a later pod of the
175
+ issue starts without it. Under
176
+ `$HOME` — `/home/legion`, which no volume is mounted on — it is your role's own home, an overlay
177
+ over the image's whose writes land on the pod's disk (an `emptyDir` every role's home and `/tmp`
178
+ share): one copy per role, per pod, kept across the role's relaunches in that pod and gone when
179
+ the pod is replaced, and charged to the pod's ephemeral-storage limit, which every role of the pod
180
+ shares and which is smaller than the volume. Moving exactly these writes off that
175
181
  filesystem is what `roleEnvironment` (`packages/daemon/internal/runtime/sandbox/manifest.go`)
176
182
  exists for, after GKE Autopilot evicted issue pods that charged their caches to it — so an install
177
183
  under `$HOME` fails the first condition and is outside the sanctioned form, however little else it
178
184
  changes. The three artefacts above are the whole of that line's ground. Disk is not: every cache is
179
185
  already relocated onto the issue's volume, which has the room for them (`docs/kubernetes.md`, "The
180
186
  tools a role's own work needs", carries the figure), and a sanctioned install is paid once per
181
- issue, on the issue's own disk, by the issue's roles together.
187
+ issue pod, on the issue's own disk, by the issue's roles together.
182
188
 
183
189
  **The worked case, and the one a role actually meets: the Dispatch e2e stack.** A change to
184
190
  `packages/dispatch` is proved by driving its browser suite, which needs a Postgres with pgvector;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "10.29.2",
3
+ "version": "11.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "legion": {
15
- "daemonApiVersion": 36
15
+ "daemonApiVersion": 39
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",