@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 +104 -14
- package/dist/skills/legion-architect/SKILL.md +2 -1
- package/dist/skills/legion-architect/references/acceptance.md +8 -5
- package/dist/skills/legion-architect/references/decomposition.md +17 -9
- package/dist/skills/legion-architect/references/wake-routing.md +3 -3
- package/dist/skills/legion-controller/SKILL.md +3 -1
- package/dist/skills/legion-controller/references/admission.md +2 -1
- package/dist/skills/legion-controller/references/daily-report.md +3 -0
- package/dist/skills/legion-controller/references/wakes.md +8 -1
- package/dist/skills/legion-worker/SKILL.md +1 -1
- package/dist/skills/legion-worker/references/merge-gate.md +9 -7
- package/dist/skills/legion-worker/references/tools.md +31 -25
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
15257
|
-
|
|
15258
|
-
|
|
15259
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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:
|
|
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
|
-
-
|
|
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
|
-
|
|
124
|
-
|
|
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.
|
|
139
|
-
child
|
|
140
|
-
|
|
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.
|
|
60
|
-
|
|
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
|
|
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.
|
|
72
|
-
|
|
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>`
|
|
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
|
|
23
|
-
| `child-closed` | Read the child completion and remaining open children. Re-scope open work, park obsolete work with `park_child
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
255
|
-
your claim with its session and the issue's
|
|
256
|
-
|
|
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.
|
|
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
|
|
283
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
90
|
-
variable the runtime sets for you there or under a path on that volume your
|
|
91
|
-
itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under
|
|
92
|
-
the runtime's own directories (`/legion/repos`, `/legion/feed
|
|
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
|
|
95
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
names its browser broker's lock from the state root and an
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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": "
|
|
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":
|
|
15
|
+
"daemonApiVersion": 39
|
|
16
16
|
},
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|