@sjawhar/pi-legion 11.6.0 → 12.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/README.md
CHANGED
|
@@ -55,7 +55,7 @@ entry, writes the notices and stages this package's partition of `skills/`. It r
|
|
|
55
55
|
the committed manifest, whose `omp.extensions` names `extensions/legion.ts`; the release workflow's
|
|
56
56
|
`pi_legion_build` job (`.github/workflows/release.yaml`), the worker image and the e2e pack step
|
|
57
57
|
rewrite it to `["dist/legion.js"]` before `bun pm pack`. Only the e2e pack step restores it
|
|
58
|
-
afterwards: the build job's tree is discarded, and `
|
|
58
|
+
afterwards: the build job's tree is discarded, and the `publish` job commits the version bump to
|
|
59
59
|
the committed manifest, touching `.version` alone. The manifest's `legion` key carries the daemon
|
|
60
60
|
API contract the plugin was built against; `src/daemon-api-version.test.ts` pins it to the daemon's
|
|
61
61
|
golden fixture.
|
package/dist/legion.js
CHANGED
|
@@ -5149,7 +5149,10 @@ var IssueEventPayloadSchema = object({
|
|
|
5149
5149
|
status: string2().optional(),
|
|
5150
5150
|
priority: number2().int().min(0).max(3).nullable().optional(),
|
|
5151
5151
|
rank: string2().optional(),
|
|
5152
|
-
route: string2().nullish()
|
|
5152
|
+
route: string2().nullish(),
|
|
5153
|
+
changed: array(string2()).optional(),
|
|
5154
|
+
ancestors: array(string2()).optional(),
|
|
5155
|
+
thread_logins: array(string2()).optional()
|
|
5153
5156
|
});
|
|
5154
5157
|
var ArtifactCreatedEventPayloadSchema = object({
|
|
5155
5158
|
artifact: object({ id: string2().optional(), slug: string2().optional(), name: string2().optional() }).optional()
|
|
@@ -5203,11 +5206,12 @@ var askEventPayloadFields = {
|
|
|
5203
5206
|
primary: boolean2().optional()
|
|
5204
5207
|
}).optional(),
|
|
5205
5208
|
resolution: object({
|
|
5206
|
-
kind: _enum(["retracted", "resolved"]).optional(),
|
|
5209
|
+
kind: _enum(["retracted", "resolved", "withdrawn"]).optional(),
|
|
5207
5210
|
reason: string2().optional(),
|
|
5208
5211
|
actor: object({ kind: string2().optional(), id: string2().optional() }).passthrough().optional(),
|
|
5209
5212
|
at: string2().optional()
|
|
5210
|
-
}).nullish()
|
|
5213
|
+
}).nullish(),
|
|
5214
|
+
thread_logins: array(string2()).optional()
|
|
5211
5215
|
};
|
|
5212
5216
|
var AskEventPayloadSchema = intersection(object(askEventPayloadFields), object({
|
|
5213
5217
|
previous: never().optional(),
|
|
@@ -5269,7 +5273,8 @@ var CommentEventPayloadSchema = object({
|
|
|
5269
5273
|
author: object({ kind: string2(), id: string2() }).optional(),
|
|
5270
5274
|
created_at: string2().optional(),
|
|
5271
5275
|
mentions: array(CommentMentionSchema).optional(),
|
|
5272
|
-
deliveries: array(CommentDeliverySchema).optional()
|
|
5276
|
+
deliveries: array(CommentDeliverySchema).optional(),
|
|
5277
|
+
thread_logins: array(string2()).optional()
|
|
5273
5278
|
}).passthrough();
|
|
5274
5279
|
var MessageEventPayloadSchema = object({
|
|
5275
5280
|
id: string2().optional(),
|
|
@@ -5278,7 +5283,8 @@ var MessageEventPayloadSchema = object({
|
|
|
5278
5283
|
in_reply_to: string2().nullish(),
|
|
5279
5284
|
reply_body: string2().optional(),
|
|
5280
5285
|
thread_target: string2().optional(),
|
|
5281
|
-
author: object({ kind: string2(), id: string2() }).optional()
|
|
5286
|
+
author: object({ kind: string2(), id: string2() }).optional(),
|
|
5287
|
+
thread_logins: array(string2()).optional()
|
|
5282
5288
|
});
|
|
5283
5289
|
var DispatchTargetedResourceIDSchema = uuid2();
|
|
5284
5290
|
var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
|
|
@@ -5508,7 +5514,7 @@ var dispatchToolSpecs = [
|
|
|
5508
5514
|
priority: 1,
|
|
5509
5515
|
before: "DSP-2"
|
|
5510
5516
|
},
|
|
5511
|
-
description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, set the repository it targets, or move it on its project's " + "board. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only a board move " + "(before, after), components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
|
|
5517
|
+
description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, set the repository it targets, or move it on its project's " + "board. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. Closing an issue withdraws every open ask " + "it holds (resolution kind withdrawn, naming the closer), and reopening it brings exactly " + "those asks back. A closed issue takes only a board move " + "(before, after), components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
|
|
5512
5518
|
arguments: (z) => ({
|
|
5513
5519
|
issue: z.string().describe(ISSUE_REFERENCE),
|
|
5514
5520
|
status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
|
|
@@ -5535,7 +5541,7 @@ var dispatchToolSpecs = [
|
|
|
5535
5541
|
{
|
|
5536
5542
|
name: "dispatch_claim",
|
|
5537
5543
|
example: { issue: "DSP-1" },
|
|
5538
|
-
description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
|
|
5544
|
+
description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. While you hold it, a person's answers, " + "comments and messages on the issue reach your session directly (a new comment that mentions you " + "arrives once, as the mention, when the mention reached you; when the mention's send fails, the " + "comment still reaches you directly; a mention whose receipt timed out can arrive twice, and the " + "second copy is the same comment); an agent's events and every other change on the issue still " + "need a subscription to it, except in an ask you follow: anyone else's answer, hand-back, " + "resolution or edit of it (other than a quiet edit), and every reply there (new, edited, resolved " + "or reopened), reach you " + "as its follower. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
|
|
5539
5545
|
arguments: (z) => ({
|
|
5540
5546
|
issue: z.string().describe(ISSUE_REFERENCE),
|
|
5541
5547
|
release: z.boolean().describe("Give up your claim instead of taking it; the issue's status does not change.").optional()
|
|
@@ -5622,7 +5628,7 @@ var dispatchToolSpecs = [
|
|
|
5622
5628
|
kind: "retracted",
|
|
5623
5629
|
reason: "A newer question supersedes this one."
|
|
5624
5630
|
},
|
|
5625
|
-
description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person.",
|
|
5631
|
+
description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person. An issue's " + "close withdrew its open asks (kind withdrawn, which only Dispatch writes, never this tool); " + "an ask on a closed issue is refused (409 ISSUE_CLOSED): reopen the issue first.",
|
|
5626
5632
|
arguments: (z) => ({
|
|
5627
5633
|
ask: z.string().describe("Ask id (uuid) to close; an 8+ hex prefix unique among this session's own open asks " + "works too."),
|
|
5628
5634
|
kind: z.enum(["retracted", "resolved"]).describe("Whether the ask is retracted or self-resolved."),
|
|
@@ -7489,7 +7495,7 @@ async function findHandoffCommit(input) {
|
|
|
7489
7495
|
|
|
7490
7496
|
// ../pi-shared/src/interface.ts
|
|
7491
7497
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
7492
|
-
var ENVOY_PLUGIN_INTERFACE_VERSION =
|
|
7498
|
+
var ENVOY_PLUGIN_INTERFACE_VERSION = 4;
|
|
7493
7499
|
var ENVOY_PLUGIN_INTERFACE_KEY = Symbol.for("legion.pi-shared.envoy-plugin-interface");
|
|
7494
7500
|
var LEGION_PLUGIN_LOADED_KEY = Symbol.for("legion.pi-legion.loaded");
|
|
7495
7501
|
var LEGACY_LEGION_LOADED_KEY = Symbol.for("legion.pi-envoy.legion-loaded");
|
|
@@ -7509,7 +7515,8 @@ function envoyPluginInterface() {
|
|
|
7509
7515
|
slackRoleOwned: false
|
|
7510
7516
|
},
|
|
7511
7517
|
injectedUserTurns: new Map,
|
|
7512
|
-
bootstrappedSession: { file: undefined }
|
|
7518
|
+
bootstrappedSession: { file: undefined },
|
|
7519
|
+
startupTurn: undefined
|
|
7513
7520
|
};
|
|
7514
7521
|
store[ENVOY_PLUGIN_INTERFACE_KEY] = created;
|
|
7515
7522
|
return created;
|
|
@@ -7604,6 +7611,19 @@ function onEnvoyRoleRegained(listener) {
|
|
|
7604
7611
|
legionRoleClaimBridge().regained = listener;
|
|
7605
7612
|
}
|
|
7606
7613
|
|
|
7614
|
+
// ../pi-shared/src/startup-turn.ts
|
|
7615
|
+
function markStartupTurnSent(sessionID) {
|
|
7616
|
+
envoyPluginInterface().startupTurn = { session: sessionID, sentAt: Date.now() };
|
|
7617
|
+
}
|
|
7618
|
+
function clearStartupTurn(sessionID) {
|
|
7619
|
+
const reading = readEnvoyPluginInterface();
|
|
7620
|
+
if (reading.kind !== "present")
|
|
7621
|
+
return;
|
|
7622
|
+
if (reading.iface.startupTurn?.session !== sessionID)
|
|
7623
|
+
return;
|
|
7624
|
+
reading.iface.startupTurn = undefined;
|
|
7625
|
+
}
|
|
7626
|
+
|
|
7607
7627
|
// ../pi-shared/src/subagent-session.ts
|
|
7608
7628
|
import fs from "fs";
|
|
7609
7629
|
import path2 from "path";
|
|
@@ -7660,7 +7680,7 @@ function subagentSessionCheck() {
|
|
|
7660
7680
|
}
|
|
7661
7681
|
|
|
7662
7682
|
// extensions/legion.ts
|
|
7663
|
-
import { logger as
|
|
7683
|
+
import { logger as logger4 } from "@oh-my-pi/pi-utils";
|
|
7664
7684
|
|
|
7665
7685
|
// src/capability-report.ts
|
|
7666
7686
|
import { readdir, stat } from "fs/promises";
|
|
@@ -8158,7 +8178,7 @@ async function bootLegionClaim(host) {
|
|
|
8158
8178
|
// package.json
|
|
8159
8179
|
var package_default = {
|
|
8160
8180
|
name: "@sjawhar/pi-legion",
|
|
8161
|
-
version: "
|
|
8181
|
+
version: "12.0.0",
|
|
8162
8182
|
license: "Apache-2.0",
|
|
8163
8183
|
type: "module",
|
|
8164
8184
|
omp: {
|
|
@@ -8170,7 +8190,7 @@ var package_default = {
|
|
|
8170
8190
|
]
|
|
8171
8191
|
},
|
|
8172
8192
|
legion: {
|
|
8173
|
-
daemonApiVersion:
|
|
8193
|
+
daemonApiVersion: 41
|
|
8174
8194
|
},
|
|
8175
8195
|
repository: {
|
|
8176
8196
|
type: "git",
|
|
@@ -8185,7 +8205,7 @@ var package_default = {
|
|
|
8185
8205
|
prepack: "../../scripts/pi-plugin-prepack.sh",
|
|
8186
8206
|
postpack: "rm -rf dist/skills",
|
|
8187
8207
|
typecheck: "bunx tsc --noEmit",
|
|
8188
|
-
test: "bun test",
|
|
8208
|
+
test: "bun test --path-ignore-patterns=extensions/controller-start-omp.test.ts && bun test ./extensions/controller-start-omp.test.ts",
|
|
8189
8209
|
lint: "bunx biome lint extensions/ src/"
|
|
8190
8210
|
},
|
|
8191
8211
|
devDependencies: {
|
|
@@ -8373,6 +8393,9 @@ function createClaimSession(deps) {
|
|
|
8373
8393
|
};
|
|
8374
8394
|
}
|
|
8375
8395
|
|
|
8396
|
+
// src/controller-session.ts
|
|
8397
|
+
import { logger as logger3 } from "@oh-my-pi/pi-utils";
|
|
8398
|
+
|
|
8376
8399
|
// src/slack-channels.ts
|
|
8377
8400
|
function daemonLaunchedController(env) {
|
|
8378
8401
|
return env.LEGION_CONTROLLER === "1" && env.LEGION_TREE === undefined && (env.LEGION_BOOT_TOKEN_FILE !== undefined || env.LEGION_BOOT_TOKEN !== undefined);
|
|
@@ -8394,30 +8417,60 @@ function daemonControllerSlackChannels(env) {
|
|
|
8394
8417
|
}
|
|
8395
8418
|
|
|
8396
8419
|
// src/controller-session.ts
|
|
8420
|
+
var CLAIM_WIDGET = "legion-controller-claim";
|
|
8421
|
+
function operatorLaunchedController(env) {
|
|
8422
|
+
return classifySession(env).kind === "controller" && !daemonLaunchedController(env);
|
|
8423
|
+
}
|
|
8397
8424
|
function createControllerSession(deps) {
|
|
8398
8425
|
const { daemon, persistedTranscript, pi } = deps;
|
|
8399
8426
|
let controllerSessionID;
|
|
8400
8427
|
let controllerCapability;
|
|
8401
8428
|
let mintControllerGrant;
|
|
8402
8429
|
let startMessageSent = false;
|
|
8430
|
+
let claimComplete = false;
|
|
8403
8431
|
let controllerTranscript;
|
|
8404
|
-
|
|
8432
|
+
let claimChain = Promise.resolve();
|
|
8433
|
+
let claimsQueued = 0;
|
|
8434
|
+
let startHandedOver = false;
|
|
8435
|
+
let claimWidget;
|
|
8436
|
+
let startTurnSession;
|
|
8437
|
+
const serialised = (task) => {
|
|
8438
|
+
claimsQueued += 1;
|
|
8439
|
+
const run = claimChain.then(task).finally(() => {
|
|
8440
|
+
claimsQueued -= 1;
|
|
8441
|
+
});
|
|
8442
|
+
claimChain = run.catch(() => {
|
|
8443
|
+
return;
|
|
8444
|
+
});
|
|
8445
|
+
return run;
|
|
8446
|
+
};
|
|
8447
|
+
const claim = (context) => queueClaim(context, { registered: false });
|
|
8448
|
+
const noteWait = (context) => {
|
|
8449
|
+
if (claimsQueued > 0) {
|
|
8450
|
+
context.ui.notify("legion: waiting for the controller claim already running to finish.", "info");
|
|
8451
|
+
}
|
|
8452
|
+
};
|
|
8453
|
+
const queueClaim = (context, progress) => {
|
|
8454
|
+
noteWait(context);
|
|
8455
|
+
return serialised(() => claimOnce(context, progress));
|
|
8456
|
+
};
|
|
8457
|
+
const claimOnce = async (context, progress) => {
|
|
8405
8458
|
const supervised = daemonLaunchedController(process.env);
|
|
8406
8459
|
if (!supervised)
|
|
8407
|
-
return claimWith(context, false);
|
|
8460
|
+
return claimWith(context, false, progress);
|
|
8408
8461
|
try {
|
|
8409
|
-
await claimWith(context, true);
|
|
8462
|
+
await claimWith(context, true, progress);
|
|
8410
8463
|
} catch (error) {
|
|
8411
|
-
|
|
8464
|
+
logger3.error(`[legion] the daemon-launched controller's claim failed; exiting so the daemon launches it again: ${messageFor(error)}`);
|
|
8412
8465
|
deps.exitProcess(1);
|
|
8413
8466
|
}
|
|
8414
8467
|
};
|
|
8415
|
-
const claimWith = async (context, supervised) => {
|
|
8468
|
+
const claimWith = async (context, supervised, progress) => {
|
|
8416
8469
|
const sessionID = context.sessionManager.getSessionId();
|
|
8417
8470
|
const capability = controllerCapability ?? (supervised ? requiredSecret(process.env, "LEGION_BOOT_TOKEN") : requiredControllerCapability(process.env));
|
|
8418
8471
|
controllerCapability = capability;
|
|
8419
8472
|
const launched = classifySession(process.env).kind === "controller";
|
|
8420
|
-
const startMessage =
|
|
8473
|
+
const startMessage = operatorLaunchedController(process.env) ? requiredEnvironment(process.env, "LEGION_CONTROLLER_START_MESSAGE") : undefined;
|
|
8421
8474
|
const { sessionFile, agentId } = await persistedTranscript(context);
|
|
8422
8475
|
if (launched)
|
|
8423
8476
|
recordBootstrappedSession(sessionFile);
|
|
@@ -8437,24 +8490,41 @@ function createControllerSession(deps) {
|
|
|
8437
8490
|
pluginContract: package_default.legion.daemonApiVersion
|
|
8438
8491
|
}).catch((error) => {
|
|
8439
8492
|
if (error instanceof LegionDaemonApiError) {
|
|
8440
|
-
|
|
8493
|
+
logger3.error(`[legion] claims/register for the controller failed (${error.status}): ${error.detail}`);
|
|
8441
8494
|
}
|
|
8442
8495
|
throw error;
|
|
8443
8496
|
});
|
|
8444
8497
|
if (controllerToken(project) !== registration.claimToken) {
|
|
8445
8498
|
throw new Error(`LEGION_PROJECT ${project} names controller role ${controllerToken(project)}, but the daemon registered this controller as ${registration.claimToken}`);
|
|
8446
8499
|
}
|
|
8500
|
+
claimComplete = false;
|
|
8501
|
+
mintControllerGrant = () => client.controllerGrant({ sessionId: sessionID, secret: registration.secret });
|
|
8502
|
+
controllerSessionID = sessionID;
|
|
8503
|
+
controllerTranscript = launched ? sessionFile : undefined;
|
|
8504
|
+
progress.registered = true;
|
|
8447
8505
|
const envoyContext = "setInterval" in context ? context : undefined;
|
|
8448
|
-
await claimEnvoyRole(sessionID, registration.claimToken, envoyContext);
|
|
8449
8506
|
if (startMessage !== undefined && !startMessageSent) {
|
|
8450
|
-
|
|
8507
|
+
if (context.sessionManager.getSessionId() !== sessionID) {
|
|
8508
|
+
startHandedOver = true;
|
|
8509
|
+
return;
|
|
8510
|
+
}
|
|
8511
|
+
if (context.isIdle?.() === false) {
|
|
8512
|
+
pi.sendUserMessage(startMessage, { deliverAs: "followUp" });
|
|
8513
|
+
} else {
|
|
8514
|
+
markStartupTurnSent(sessionID);
|
|
8515
|
+
startTurnSession = sessionID;
|
|
8516
|
+
pi.sendUserMessage(startMessage);
|
|
8517
|
+
}
|
|
8451
8518
|
startMessageSent = true;
|
|
8452
8519
|
}
|
|
8520
|
+
await claimEnvoyRole(sessionID, registration.claimToken, envoyContext);
|
|
8453
8521
|
await subscribeLegionNotice(sessionID, legionControllerNoticeSubject(project), envoyContext, registration.claimToken);
|
|
8454
8522
|
for (const channel of slackChannels) {
|
|
8455
8523
|
await subscribeLegionNotice(sessionID, slackMentionTopic(channel), envoyContext, registration.claimToken);
|
|
8456
8524
|
}
|
|
8457
8525
|
if (supervised) {
|
|
8526
|
+
markStartupTurnSent(sessionID);
|
|
8527
|
+
startTurnSession = sessionID;
|
|
8458
8528
|
await client.ready({
|
|
8459
8529
|
claimToken: registration.claimToken,
|
|
8460
8530
|
sessionId: sessionID,
|
|
@@ -8462,30 +8532,100 @@ function createControllerSession(deps) {
|
|
|
8462
8532
|
generation: registration.generation
|
|
8463
8533
|
});
|
|
8464
8534
|
}
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8535
|
+
claimComplete = true;
|
|
8536
|
+
claimWidget?.clear();
|
|
8537
|
+
claimWidget = undefined;
|
|
8468
8538
|
};
|
|
8469
8539
|
const handleSessionStart = async (context) => {
|
|
8470
|
-
if (
|
|
8540
|
+
if (!daemonLaunchedController(process.env))
|
|
8471
8541
|
return;
|
|
8472
8542
|
const sessionID = context.sessionManager.getSessionId();
|
|
8473
8543
|
if (controllerSessionID === undefined || controllerSessionID === sessionID) {
|
|
8474
8544
|
await claim(context);
|
|
8475
8545
|
}
|
|
8476
8546
|
};
|
|
8547
|
+
const startCommand = (context) => {
|
|
8548
|
+
noteWait(context);
|
|
8549
|
+
return serialised(async () => {
|
|
8550
|
+
if (!operatorLaunchedController(process.env) || claimComplete) {
|
|
8551
|
+
context.ui.notify("legion: nothing to do: the controller claim already succeeded in this process, or this session claims no controller from this command (a hand-started takeover, or the controller the daemon launched).", "info");
|
|
8552
|
+
return;
|
|
8553
|
+
}
|
|
8554
|
+
if (startMessageSent) {
|
|
8555
|
+
context.ui.notify("legion: this process already sent the start message, but its controller claim did not finish; run /legion-claim-controller to claim the controller role and its wakes again.", "warning");
|
|
8556
|
+
return;
|
|
8557
|
+
}
|
|
8558
|
+
try {
|
|
8559
|
+
await claimOnce(context, { registered: false });
|
|
8560
|
+
} catch (error) {
|
|
8561
|
+
const message = messageFor(error);
|
|
8562
|
+
logger3.error(`[legion] the controller claim failed: ${message}`);
|
|
8563
|
+
context.ui.notify(startMessageSent ? `legion: the controller claim failed after the start message was sent: ${message}. The start turn has the controller's registration; run /legion-claim-controller to claim the controller role and its wakes again.` : `legion: the controller claim failed: ${message}. Run /legion-claim-controller to claim again; it sends the start message once the claim succeeds.`, "warning");
|
|
8564
|
+
}
|
|
8565
|
+
});
|
|
8566
|
+
};
|
|
8477
8567
|
const reclaimAfterSessionChange = async (context) => {
|
|
8478
8568
|
if (classifySession(process.env).kind !== "controller")
|
|
8479
8569
|
return;
|
|
8480
|
-
if (context.sessionManager.getSessionId() === controllerSessionID && context.sessionManager.getSessionFile() === controllerTranscript) {
|
|
8570
|
+
if (claimComplete && context.sessionManager.getSessionId() === controllerSessionID && context.sessionManager.getSessionFile() === controllerTranscript) {
|
|
8571
|
+
if (claimWidget !== undefined && claimWidget.session !== controllerSessionID) {
|
|
8572
|
+
claimWidget.clear();
|
|
8573
|
+
claimWidget = undefined;
|
|
8574
|
+
}
|
|
8481
8575
|
return;
|
|
8482
8576
|
}
|
|
8577
|
+
const notices = [];
|
|
8578
|
+
let shown = false;
|
|
8579
|
+
const notify = (message, level) => {
|
|
8580
|
+
if (shown)
|
|
8581
|
+
context.ui.notify(message, level);
|
|
8582
|
+
else
|
|
8583
|
+
notices.push({ message, level });
|
|
8584
|
+
};
|
|
8585
|
+
const deferred = Object.create(context, { ui: { value: { notify } } });
|
|
8586
|
+
const progress = { registered: false };
|
|
8483
8587
|
try {
|
|
8484
|
-
|
|
8588
|
+
noteWait(context);
|
|
8589
|
+
await serialised(async () => {
|
|
8590
|
+
if (startHandedOver) {
|
|
8591
|
+
startHandedOver = false;
|
|
8592
|
+
notify("legion: the session changed while the controller claim ran; the claim for the new session follows and sends the start message there.", "info");
|
|
8593
|
+
}
|
|
8594
|
+
await claimOnce(deferred, progress);
|
|
8595
|
+
});
|
|
8485
8596
|
} catch (error) {
|
|
8486
|
-
|
|
8597
|
+
notify(progress.registered ? `legion: re-claiming the controller for this session failed (${messageFor(error)}). The session keeps its controller registration, so the \`legion\` tool's set_status still works, but until a re-claim succeeds controller wakes and merges will not reach this session.` : `legion: re-claiming the controller for this session failed (${messageFor(error)}). Until a re-claim succeeds, controller wakes and merges will not reach this session, and the \`legion\` tool's set_status and its other operations that call the daemon have no controller grant to mint and fail.`, "warning");
|
|
8598
|
+
} finally {
|
|
8599
|
+
const { ui } = context;
|
|
8600
|
+
if (ui.setWidget === undefined) {
|
|
8601
|
+
context.setTimeout(() => {
|
|
8602
|
+
shown = true;
|
|
8603
|
+
for (const { message, level } of notices.splice(0))
|
|
8604
|
+
ui.notify(message, level);
|
|
8605
|
+
}, 0);
|
|
8606
|
+
} else {
|
|
8607
|
+
shown = true;
|
|
8608
|
+
if (notices.length > 0) {
|
|
8609
|
+
ui.setWidget(CLAIM_WIDGET, notices.splice(0).map(({ message }) => message));
|
|
8610
|
+
claimWidget = {
|
|
8611
|
+
session: context.sessionManager.getSessionId(),
|
|
8612
|
+
clear: () => ui.setWidget?.(CLAIM_WIDGET, undefined)
|
|
8613
|
+
};
|
|
8614
|
+
}
|
|
8615
|
+
}
|
|
8487
8616
|
}
|
|
8488
8617
|
};
|
|
8618
|
+
const handleAgentStart = (context) => {
|
|
8619
|
+
const sessionID = context.sessionManager.getSessionId();
|
|
8620
|
+
if (startTurnSession === sessionID) {
|
|
8621
|
+
startTurnSession = undefined;
|
|
8622
|
+
return;
|
|
8623
|
+
}
|
|
8624
|
+
if (claimWidget?.session !== sessionID)
|
|
8625
|
+
return;
|
|
8626
|
+
claimWidget.clear();
|
|
8627
|
+
claimWidget = undefined;
|
|
8628
|
+
};
|
|
8489
8629
|
const isClaimedSession = (sessionID) => controllerSessionID === sessionID && mintControllerGrant !== undefined;
|
|
8490
8630
|
const mintGrant = async (sessionID) => {
|
|
8491
8631
|
if (controllerSessionID !== sessionID || mintControllerGrant === undefined) {
|
|
@@ -8493,7 +8633,15 @@ function createControllerSession(deps) {
|
|
|
8493
8633
|
}
|
|
8494
8634
|
return mintControllerGrant();
|
|
8495
8635
|
};
|
|
8496
|
-
return {
|
|
8636
|
+
return {
|
|
8637
|
+
claim,
|
|
8638
|
+
handleSessionStart,
|
|
8639
|
+
handleAgentStart,
|
|
8640
|
+
reclaimAfterSessionChange,
|
|
8641
|
+
startCommand,
|
|
8642
|
+
isClaimedSession,
|
|
8643
|
+
mintGrant
|
|
8644
|
+
};
|
|
8497
8645
|
}
|
|
8498
8646
|
|
|
8499
8647
|
// src/phase-stall.ts
|
|
@@ -9004,7 +9152,7 @@ function envoyPluginRefusal() {
|
|
|
9004
9152
|
}
|
|
9005
9153
|
function legionExtension(pi) {
|
|
9006
9154
|
const instance = randomUUID().slice(0, 8);
|
|
9007
|
-
|
|
9155
|
+
logger4.debug("extension instance loaded", { extension: import.meta.url, instance });
|
|
9008
9156
|
globalThis[LEGION_PLUGIN_LOADED_KEY] = {
|
|
9009
9157
|
from: import.meta.url,
|
|
9010
9158
|
envoyInterface: ENVOY_PLUGIN_INTERFACE_VERSION
|
|
@@ -9071,7 +9219,7 @@ function legionExtension(pi) {
|
|
|
9071
9219
|
return;
|
|
9072
9220
|
const refusal = envoyPluginRefusal();
|
|
9073
9221
|
if (refusal !== undefined) {
|
|
9074
|
-
|
|
9222
|
+
logger4.error(refusal);
|
|
9075
9223
|
exitProcess(1);
|
|
9076
9224
|
}
|
|
9077
9225
|
if (session.kind === "controller") {
|
|
@@ -9099,8 +9247,13 @@ function legionExtension(pi) {
|
|
|
9099
9247
|
resentNoticeId = recreatedNotice.id;
|
|
9100
9248
|
return { message: workspaceRecreatedMessage(recreatedNotice) };
|
|
9101
9249
|
});
|
|
9102
|
-
pi.on("agent_start", async () => {
|
|
9250
|
+
pi.on("agent_start", async (_event, context) => {
|
|
9103
9251
|
recreatedNotice = undefined;
|
|
9252
|
+
clearStartupTurn(context.sessionManager.getSessionId());
|
|
9253
|
+
controllerSession.handleAgentStart(context);
|
|
9254
|
+
});
|
|
9255
|
+
pi.on("session_shutdown", async (_event, context) => {
|
|
9256
|
+
clearStartupTurn(context.sessionManager.getSessionId());
|
|
9104
9257
|
});
|
|
9105
9258
|
pi.on("context", async (event) => {
|
|
9106
9259
|
if (resentNoticeId === undefined)
|
|
@@ -9193,6 +9346,12 @@ function legionExtension(pi) {
|
|
|
9193
9346
|
await activateLegionTool();
|
|
9194
9347
|
}
|
|
9195
9348
|
});
|
|
9349
|
+
pi.registerCommand("legion-controller-start", {
|
|
9350
|
+
description: "Claim the Legion controller and send its start message as the session's first turn; `legion controller start` passes this command on Oh My Pi's command line",
|
|
9351
|
+
handler: async (_args, context) => {
|
|
9352
|
+
await controllerSession.startCommand(context);
|
|
9353
|
+
}
|
|
9354
|
+
});
|
|
9196
9355
|
}
|
|
9197
9356
|
export {
|
|
9198
9357
|
legionExtension as default,
|
|
@@ -30,9 +30,9 @@ unassigned.
|
|
|
30
30
|
|
|
31
31
|
Each time you read it:
|
|
32
32
|
|
|
33
|
-
- `Status: done` means someone closed it
|
|
34
|
-
|
|
35
|
-
in the day's report.
|
|
33
|
+
- `Status: done` means someone closed it: the close withdrew its open asks (each recorded
|
|
34
|
+
`withdrawn`; a reopen brings them back), and Dispatch refuses an ask or a message there
|
|
35
|
+
(`409 ISSUE_CLOSED`). Reopen it with `set_status` to `icebox` and say so in the day's report.
|
|
36
36
|
- `Assignee: unassigned` with a person known: assign it, naming your session (`dispatch whoami`):
|
|
37
37
|
`curl -sS -X PATCH -H "Authorization: Bearer $(cat "$DISPATCH_TOKEN_FILE")" -H 'Content-Type: application/json' -d '{"assignee": "<email>", "actor": {"kind": "session", "id": "<session id>"}}' "$DISPATCH_URL/api/v1/issues/<REPORT KEY>"`.
|
|
38
38
|
An answer that does not print the issue with its new `assignee` left the assignee as it was
|
|
@@ -118,9 +118,9 @@ An ask goes on the controller's report issue whenever it is open: the issue the
|
|
|
118
118
|
instructions name for Legion's reports, else the project's `Legion daily report`, which
|
|
119
119
|
`dispatch search --query '"Legion daily report"' --project <PROJECT>` finds. It stays open in
|
|
120
120
|
`icebox`, so the ask stays answerable whatever happens to the issue it came from; once an issue
|
|
121
|
-
closes,
|
|
122
|
-
(`409 ISSUE_CLOSED`). The question names the issue the finding came from by its
|
|
123
|
-
reference.
|
|
121
|
+
closes, its close withdraws its open asks (a reopen brings them back), and Dispatch refuses their
|
|
122
|
+
answers (`409 ISSUE_CLOSED`). The question names the issue the finding came from by its
|
|
123
|
+
`dispatch://` reference.
|
|
124
124
|
|
|
125
125
|
When the project has no report issue yet, or a person closed it (`dispatch read` shows `Status:
|
|
126
126
|
done`, and Dispatch refuses the ask with `409 ISSUE_CLOSED`), do not create or reopen one yourself
|
|
@@ -289,10 +289,11 @@ reviewing until you finish.
|
|
|
289
289
|
repository's deploy workflow (`projects.<KEY>.deploys`), at each completed run of that workflow
|
|
290
290
|
on the default branch whose named step (such as `Apply`) succeeded after the merge, once per such
|
|
291
291
|
job; or, for `sjawhar/legion`, on a Legion daemon's boot on a build containing the merge, on
|
|
292
|
-
Dispatch's `server.started`
|
|
293
|
-
its boot. The woken look's task names the event
|
|
292
|
+
Dispatch's `server.started` announcing that every server process serves such a build, or on the
|
|
293
|
+
build the daemon read from Dispatch at its boot. The woken look's task names the event
|
|
294
|
+
(`Dispatch's issue.deployed: run <id> completed <t>`,
|
|
294
295
|
`the deploy workflow's run: <repo> <workflow> run <id> attempt <n>, job <job name>: <step> succeeded at <t>`,
|
|
295
|
-
`the Legion daemon's boot on build <sha>`, `Dispatch
|
|
296
|
+
`the Legion daemon's boot on build <sha>`, `Dispatch serving build <sha> on every server process`,
|
|
296
297
|
`Dispatch's build <sha>`), and its `Production:` line names it too, so the pull request carries
|
|
297
298
|
the cause. Never poll for the deploy, never wait on a reply, and never ask anyone for approval
|
|
298
299
|
to deploy. Open a `dispatch ask` to a person only when the instructions name nobody for that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/pi-legion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.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": 41
|
|
16
16
|
},
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"prepack": "../../scripts/pi-plugin-prepack.sh",
|
|
28
28
|
"postpack": "rm -rf dist/skills",
|
|
29
29
|
"typecheck": "bunx tsc --noEmit",
|
|
30
|
-
"test": "bun test",
|
|
30
|
+
"test": "bun test --path-ignore-patterns=extensions/controller-start-omp.test.ts && bun test ./extensions/controller-start-omp.test.ts",
|
|
31
31
|
"lint": "bunx biome lint extensions/ src/"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|