@sjawhar/pi-legion-envoy 5.4.2 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/envoy.js +102 -22
- package/dist/legion.js +27 -6
- package/dist/skills/dispatch/SKILL.md +8 -0
- package/dist/skills/envoy/SKILL.md +9 -0
- package/dist/skills/legion-controller/SKILL.md +21 -3
- package/package.json +2 -2
package/dist/envoy.js
CHANGED
|
@@ -30520,6 +30520,9 @@ var LEGION_NOTICE_TOPIC_PREFIX = "notifications.legion.";
|
|
|
30520
30520
|
function legionNoticeSubject(project, issue2) {
|
|
30521
30521
|
return `${LEGION_NOTICE_TOPIC_PREFIX}${project}.${issue2}`;
|
|
30522
30522
|
}
|
|
30523
|
+
function legionControllerNoticeSubject(project) {
|
|
30524
|
+
return legionNoticeSubject(project, "controller");
|
|
30525
|
+
}
|
|
30523
30526
|
var DISPATCH_TOPIC_PREFIX = "notifications.dispatch.";
|
|
30524
30527
|
var DISPATCH_ISSUE_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}issue.`;
|
|
30525
30528
|
var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
|
|
@@ -30549,6 +30552,12 @@ function isLegionRole(role) {
|
|
|
30549
30552
|
function isLegionProjectToken(project) {
|
|
30550
30553
|
return PROJECT_TOKEN.test(project);
|
|
30551
30554
|
}
|
|
30555
|
+
function legionProjectToken(value, field) {
|
|
30556
|
+
const project = value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
30557
|
+
if (!project)
|
|
30558
|
+
throw new Error(`${field} must include at least one alphanumeric character`);
|
|
30559
|
+
return project;
|
|
30560
|
+
}
|
|
30552
30561
|
function assertLegionProjectToken(project) {
|
|
30553
30562
|
if (!isLegionProjectToken(project)) {
|
|
30554
30563
|
throw new Error(`Invalid Legion project token: ${project}`);
|
|
@@ -34916,12 +34925,12 @@ async function claimEnvoyRole(sessionID, role, context) {
|
|
|
34916
34925
|
throw new Error("Envoy has no bound instance for a role claim");
|
|
34917
34926
|
await instance.claim(sessionID, role, context);
|
|
34918
34927
|
}
|
|
34919
|
-
async function subscribeLegionNotice(sessionID, topic, context) {
|
|
34928
|
+
async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
|
|
34920
34929
|
const bridge = legionRoleClaimBridge();
|
|
34921
34930
|
const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
|
|
34922
34931
|
if (instance === undefined)
|
|
34923
34932
|
throw new Error("Envoy has no bound instance for a notice subscription");
|
|
34924
|
-
await instance.subscribe(sessionID, topic, context);
|
|
34933
|
+
await instance.subscribe(sessionID, topic, context, whileHolding);
|
|
34925
34934
|
}
|
|
34926
34935
|
function onEnvoyRoleRegained(listener) {
|
|
34927
34936
|
legionRoleClaimBridge().regained = listener;
|
|
@@ -35030,13 +35039,45 @@ var ROLE_CLAIM_ENTRY = "envoy-role-claim";
|
|
|
35030
35039
|
var OPEN_ASKS_TIMEOUT_MS = 3000;
|
|
35031
35040
|
var LEGION_MANAGED_ENTRY = "legion-managed-session";
|
|
35032
35041
|
var ASK_REMINDER_MESSAGE = "dispatch-ask-reminder";
|
|
35033
|
-
var
|
|
35042
|
+
var ASK_PROMPT_LINE_SEPARATOR = /\r\n|[\r\n\u0085\u2028\u2029]/;
|
|
35043
|
+
function askPromptQuestionHead(question) {
|
|
35044
|
+
const firstLineEnd = question.search(ASK_PROMPT_LINE_SEPARATOR);
|
|
35045
|
+
const firstLine = firstLineEnd === -1 ? question : question.slice(0, firstLineEnd);
|
|
35046
|
+
let normalized;
|
|
35047
|
+
let segmentStart = 0;
|
|
35048
|
+
for (let index = 0;index < firstLine.length; index += 1) {
|
|
35049
|
+
const codePoint = firstLine.charCodeAt(index);
|
|
35050
|
+
if (codePoint > 31 && (codePoint < 127 || codePoint > 159))
|
|
35051
|
+
continue;
|
|
35052
|
+
normalized ??= [];
|
|
35053
|
+
normalized.push(firstLine.slice(segmentStart, index), " ");
|
|
35054
|
+
segmentStart = index + 1;
|
|
35055
|
+
}
|
|
35056
|
+
return (normalized === undefined ? firstLine : `${normalized.join("")}${firstLine.slice(segmentStart)}`).trim();
|
|
35057
|
+
}
|
|
35058
|
+
var ASK_SELF_CHECK_PROMPT = (asks) => {
|
|
35059
|
+
const askLimit = 5;
|
|
35060
|
+
const questionLimit = 120;
|
|
35061
|
+
const promptAsks = asks.length === 0 ? "There are no open asks in Dispatch." : [
|
|
35062
|
+
"Your open asks in Dispatch:",
|
|
35063
|
+
...asks.slice(0, askLimit).map((ask) => {
|
|
35064
|
+
const firstLine = askPromptQuestionHead(ask.question);
|
|
35065
|
+
const question = firstLine.length <= questionLimit ? firstLine : `${firstLine.slice(0, questionLimit - 1)}\u2026`;
|
|
35066
|
+
return `- ${question}`;
|
|
35067
|
+
}),
|
|
35068
|
+
...asks.length > askLimit ? [`+${asks.length - askLimit} more`] : []
|
|
35069
|
+
].join(`
|
|
35070
|
+
`);
|
|
35071
|
+
return "Your run has just ended. " + `${promptAsks}
|
|
35072
|
+
|
|
35073
|
+
` + "Are you right now waiting on a human for anything none of those asks covers? " + "Answer with exactly one word and nothing else: WAITING or PROCEEDING. " + "WAITING \u2014 you stopped because you need a decision, an approval, or information from a human. " + "PROCEEDING \u2014 you finished, you will carry on by yourself, or you are waiting only on tools, " + "subagents, or events.";
|
|
35074
|
+
};
|
|
35034
35075
|
var WAITING_VERDICT = /^\W*WAITING\b/;
|
|
35035
35076
|
var ECHOED_CHOICE = /\bPROCEEDING\b/;
|
|
35036
35077
|
var isWaitingVerdict = (reply) => WAITING_VERDICT.test(reply) && !ECHOED_CHOICE.test(reply);
|
|
35037
35078
|
var ASK_SELF_CHECK_TIMEOUT_MS = 60000;
|
|
35038
35079
|
var ASK_CHECKS_PER_PERIOD = 5;
|
|
35039
|
-
var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human
|
|
35080
|
+
var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human for something no open ask in Dispatch covers. Open an ask for it now with dispatch_ask (or dispatch_request_approval for a document), naming exactly what you need and from whom.";
|
|
35040
35081
|
var ASK_OPENING_TOOLS = ["dispatch_ask", "dispatch_request_approval"];
|
|
35041
35082
|
var DISPATCH_TOOL_PREFIX = "dispatch_";
|
|
35042
35083
|
function isLegionManagedEntry(entry) {
|
|
@@ -35104,6 +35145,9 @@ function envoyExtension(pi) {
|
|
|
35104
35145
|
let recordedSessionKey;
|
|
35105
35146
|
let heartbeatRegistered = false;
|
|
35106
35147
|
let claimedRoleTopic;
|
|
35148
|
+
let claimedRoleSessionID;
|
|
35149
|
+
const roleNoticeSubjects = new Set;
|
|
35150
|
+
let roleEnds = 0;
|
|
35107
35151
|
let activeSessionContext;
|
|
35108
35152
|
const inbox = [];
|
|
35109
35153
|
let askAwareness = {
|
|
@@ -35302,6 +35346,7 @@ function envoyExtension(pi) {
|
|
|
35302
35346
|
return;
|
|
35303
35347
|
if (intentionallyClosed.delete(topic))
|
|
35304
35348
|
return;
|
|
35349
|
+
const snapshot = roleSnapshot(topic);
|
|
35305
35350
|
const retry = (delayMs) => {
|
|
35306
35351
|
awaitingRetry.add(topic);
|
|
35307
35352
|
setTimeout(() => {
|
|
@@ -35310,7 +35355,7 @@ function envoyExtension(pi) {
|
|
|
35310
35355
|
return;
|
|
35311
35356
|
if (intentionallyClosed.delete(topic))
|
|
35312
35357
|
return;
|
|
35313
|
-
|
|
35358
|
+
subscribeUnlessRoleEnds(topic, snapshot).catch(() => retry(NATS_RETRY_INTERVAL_MS));
|
|
35314
35359
|
}, delayMs);
|
|
35315
35360
|
};
|
|
35316
35361
|
retry(RESUBSCRIBE_DELAY_MS);
|
|
@@ -35328,10 +35373,42 @@ function envoyExtension(pi) {
|
|
|
35328
35373
|
}
|
|
35329
35374
|
return true;
|
|
35330
35375
|
};
|
|
35376
|
+
const endRole = () => {
|
|
35377
|
+
claimedRoleTopic = undefined;
|
|
35378
|
+
claimedRoleSessionID = undefined;
|
|
35379
|
+
roleEnds += 1;
|
|
35380
|
+
for (const subject2 of roleNoticeSubjects)
|
|
35381
|
+
closeIntentionally(subject2);
|
|
35382
|
+
roleNoticeSubjects.clear();
|
|
35383
|
+
};
|
|
35384
|
+
const endOutgoingRole = () => {
|
|
35385
|
+
if (claimedRoleSessionID !== sessionID)
|
|
35386
|
+
endRole();
|
|
35387
|
+
};
|
|
35388
|
+
const roleSnapshot = (topic) => ({
|
|
35389
|
+
roleBound: expandSubscriptionTopics([topic]).some((subject2) => roleNoticeSubjects.has(subject2)),
|
|
35390
|
+
endsBefore: roleEnds
|
|
35391
|
+
});
|
|
35392
|
+
const subscribeUnlessRoleEnds = async (topic, snapshot) => {
|
|
35393
|
+
const roleEnded = () => snapshot.roleBound && roleEnds !== snapshot.endsBefore;
|
|
35394
|
+
if (roleEnded())
|
|
35395
|
+
return false;
|
|
35396
|
+
await subscribe(topic);
|
|
35397
|
+
if (!roleEnded())
|
|
35398
|
+
return true;
|
|
35399
|
+
for (const subject2 of expandSubscriptionTopics([topic]))
|
|
35400
|
+
closeIntentionally(subject2);
|
|
35401
|
+
return false;
|
|
35402
|
+
};
|
|
35331
35403
|
const registerSession = () => client.subscribe({
|
|
35332
35404
|
sessionID,
|
|
35333
35405
|
directory: sessionDirectory,
|
|
35334
|
-
topics: [
|
|
35406
|
+
topics: [
|
|
35407
|
+
...new Set([
|
|
35408
|
+
agentSubject(sessionID),
|
|
35409
|
+
...[...subscriptions.keys()].filter((subject2) => !roleNoticeSubjects.has(subject2))
|
|
35410
|
+
])
|
|
35411
|
+
],
|
|
35335
35412
|
port: 0,
|
|
35336
35413
|
title: activeSessionContext?.sessionManager.getSessionName?.() ?? "",
|
|
35337
35414
|
capabilities: typeof pi.askEphemeral === "function" ? DELIVERY_CAPABILITIES : CAPABILITIES_WITHOUT_BTW,
|
|
@@ -35362,7 +35439,7 @@ function envoyExtension(pi) {
|
|
|
35362
35439
|
return;
|
|
35363
35440
|
}
|
|
35364
35441
|
if (!result.claimed) {
|
|
35365
|
-
|
|
35442
|
+
endRole();
|
|
35366
35443
|
logger.warn("envoy: role re-assertion refused; held by another live session", {
|
|
35367
35444
|
role,
|
|
35368
35445
|
sessionID: id,
|
|
@@ -35444,10 +35521,13 @@ function envoyExtension(pi) {
|
|
|
35444
35521
|
sessionID,
|
|
35445
35522
|
holder: result.holder
|
|
35446
35523
|
});
|
|
35447
|
-
|
|
35524
|
+
endRole();
|
|
35448
35525
|
return false;
|
|
35449
35526
|
}
|
|
35527
|
+
if (previousTopic !== undefined && previousTopic !== topic)
|
|
35528
|
+
endRole();
|
|
35450
35529
|
claimedRoleTopic = topic;
|
|
35530
|
+
claimedRoleSessionID = sessionID;
|
|
35451
35531
|
pi.appendEntry(ROLE_CLAIM_ENTRY, { role });
|
|
35452
35532
|
if (activeSessionContext !== undefined)
|
|
35453
35533
|
ensureHeartbeat(activeSessionContext);
|
|
@@ -35468,10 +35548,10 @@ function envoyExtension(pi) {
|
|
|
35468
35548
|
const roleSources = (previousSessionID, carryPreviousSessionRole) => previousSessionID === "" || previousSessionID === sessionID || !carryPreviousSessionRole ? [sessionID] : [sessionID, previousSessionID];
|
|
35469
35549
|
const reclaimHeldRoles = async (previousSessionID, branch, carryPreviousSessionRole) => {
|
|
35470
35550
|
if (!carryPreviousSessionRole)
|
|
35471
|
-
|
|
35551
|
+
endOutgoingRole();
|
|
35472
35552
|
const remembered = transcriptClaimedRole(branch);
|
|
35473
35553
|
if (remembered === null) {
|
|
35474
|
-
|
|
35554
|
+
endRole();
|
|
35475
35555
|
return;
|
|
35476
35556
|
}
|
|
35477
35557
|
let role = remembered ?? claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
|
|
@@ -35498,7 +35578,7 @@ function envoyExtension(pi) {
|
|
|
35498
35578
|
const establishSessionNow = async (context, options = {}, previousSessionID = sessionID) => {
|
|
35499
35579
|
const previousTopic = previousSessionID === "" ? undefined : agentSubject(previousSessionID);
|
|
35500
35580
|
if (options.carryPreviousSessionRole === false)
|
|
35501
|
-
|
|
35581
|
+
endOutgoingRole();
|
|
35502
35582
|
restoreLocalSessionState(context);
|
|
35503
35583
|
const branch = context.sessionManager.getBranch?.() ?? [];
|
|
35504
35584
|
const resumed = branch.length > 0;
|
|
@@ -35542,14 +35622,22 @@ function envoyExtension(pi) {
|
|
|
35542
35622
|
await registerSession();
|
|
35543
35623
|
await setEnvoyRole(role);
|
|
35544
35624
|
};
|
|
35545
|
-
const subscribeNotice = async (targetSessionID, topic, callerContext) => {
|
|
35625
|
+
const subscribeNotice = async (targetSessionID, topic, callerContext, whileHolding) => {
|
|
35546
35626
|
const context = callerContext ?? activeSessionContext;
|
|
35547
35627
|
if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
|
|
35548
35628
|
throw new Error(`Envoy has no active session for Legion notice subscription: ${targetSessionID}`);
|
|
35549
35629
|
}
|
|
35550
35630
|
if (sessionID !== targetSessionID)
|
|
35551
35631
|
await establishSession(context);
|
|
35552
|
-
|
|
35632
|
+
if (whileHolding !== undefined) {
|
|
35633
|
+
if (claimedRoleTopic !== ROLE_TOPIC_PREFIX + whileHolding) {
|
|
35634
|
+
throw new Error(`Envoy session ${targetSessionID} does not hold role ${whileHolding} for a notice subscription`);
|
|
35635
|
+
}
|
|
35636
|
+
for (const subject2 of expandSubscriptionTopics([topic]))
|
|
35637
|
+
roleNoticeSubjects.add(subject2);
|
|
35638
|
+
}
|
|
35639
|
+
if (!await subscribeUnlessRoleEnds(topic, roleSnapshot(topic)))
|
|
35640
|
+
return;
|
|
35553
35641
|
await registerSession();
|
|
35554
35642
|
};
|
|
35555
35643
|
const claimInstance = {
|
|
@@ -35790,19 +35878,11 @@ function envoyExtension(pi) {
|
|
|
35790
35878
|
return;
|
|
35791
35879
|
if (runSeq !== seenRun)
|
|
35792
35880
|
return;
|
|
35793
|
-
if (open.snapshot.count > 0 || open.snapshot.opened_since) {
|
|
35794
|
-
askAwareness = {
|
|
35795
|
-
...askAwareness,
|
|
35796
|
-
check_due: false,
|
|
35797
|
-
baseline_as_of: open.snapshot.as_of
|
|
35798
|
-
};
|
|
35799
|
-
return;
|
|
35800
|
-
}
|
|
35801
35881
|
const abort = new AbortController;
|
|
35802
35882
|
askCheckAbort = abort;
|
|
35803
35883
|
let answered;
|
|
35804
35884
|
try {
|
|
35805
|
-
answered = askEphemeral({ prompt: ASK_SELF_CHECK_PROMPT, signal: abort.signal }).then((reply) => reply.replyText, (error48) => {
|
|
35885
|
+
answered = askEphemeral({ prompt: ASK_SELF_CHECK_PROMPT(open.snapshot.asks), signal: abort.signal }).then((reply) => reply.replyText, (error48) => {
|
|
35806
35886
|
if (!abort.signal.aborted)
|
|
35807
35887
|
logSelfCheckFailure(id, error48);
|
|
35808
35888
|
return;
|
package/dist/legion.js
CHANGED
|
@@ -16142,7 +16142,7 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
16142
16142
|
// package.json
|
|
16143
16143
|
var package_default = {
|
|
16144
16144
|
name: "@sjawhar/pi-legion-envoy",
|
|
16145
|
-
version: "5.
|
|
16145
|
+
version: "5.5.0",
|
|
16146
16146
|
type: "module",
|
|
16147
16147
|
omp: {
|
|
16148
16148
|
extensions: [
|
|
@@ -16155,7 +16155,7 @@ var package_default = {
|
|
|
16155
16155
|
},
|
|
16156
16156
|
legion: {
|
|
16157
16157
|
daemonApiVersion: 8,
|
|
16158
|
-
goDaemonApiVersion:
|
|
16158
|
+
goDaemonApiVersion: 7
|
|
16159
16159
|
},
|
|
16160
16160
|
repository: {
|
|
16161
16161
|
type: "git",
|
|
@@ -30593,6 +30593,9 @@ var LEGION_NOTICE_TOPIC_PREFIX = "notifications.legion.";
|
|
|
30593
30593
|
function legionNoticeSubject(project, issue2) {
|
|
30594
30594
|
return `${LEGION_NOTICE_TOPIC_PREFIX}${project}.${issue2}`;
|
|
30595
30595
|
}
|
|
30596
|
+
function legionControllerNoticeSubject(project) {
|
|
30597
|
+
return legionNoticeSubject(project, "controller");
|
|
30598
|
+
}
|
|
30596
30599
|
var DISPATCH_TOPIC_PREFIX = "notifications.dispatch.";
|
|
30597
30600
|
var DISPATCH_ISSUE_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}issue.`;
|
|
30598
30601
|
var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
|
|
@@ -30622,6 +30625,12 @@ function isLegionRole(role) {
|
|
|
30622
30625
|
function isLegionProjectToken(project) {
|
|
30623
30626
|
return PROJECT_TOKEN.test(project);
|
|
30624
30627
|
}
|
|
30628
|
+
function legionProjectToken(value, field) {
|
|
30629
|
+
const project = value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
30630
|
+
if (!project)
|
|
30631
|
+
throw new Error(`${field} must include at least one alphanumeric character`);
|
|
30632
|
+
return project;
|
|
30633
|
+
}
|
|
30625
30634
|
function assertLegionProjectToken(project) {
|
|
30626
30635
|
if (!isLegionProjectToken(project)) {
|
|
30627
30636
|
throw new Error(`Invalid Legion project token: ${project}`);
|
|
@@ -31234,12 +31243,12 @@ async function claimEnvoyRole(sessionID, role, context) {
|
|
|
31234
31243
|
throw new Error("Envoy has no bound instance for a role claim");
|
|
31235
31244
|
await instance.claim(sessionID, role, context);
|
|
31236
31245
|
}
|
|
31237
|
-
async function subscribeLegionNotice(sessionID, topic, context) {
|
|
31246
|
+
async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
|
|
31238
31247
|
const bridge = legionRoleClaimBridge();
|
|
31239
31248
|
const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
|
|
31240
31249
|
if (instance === undefined)
|
|
31241
31250
|
throw new Error("Envoy has no bound instance for a notice subscription");
|
|
31242
|
-
await instance.subscribe(sessionID, topic, context);
|
|
31251
|
+
await instance.subscribe(sessionID, topic, context, whileHolding);
|
|
31243
31252
|
}
|
|
31244
31253
|
function onEnvoyRoleRegained(listener) {
|
|
31245
31254
|
legionRoleClaimBridge().regained = listener;
|
|
@@ -31429,6 +31438,7 @@ var legionGoIssue = exports_external.strictObject({
|
|
|
31429
31438
|
generation: exports_external.number().int().nonnegative(),
|
|
31430
31439
|
phase: exports_external.enum(LEGION_GO_PHASES),
|
|
31431
31440
|
status: nonEmptyString2,
|
|
31441
|
+
holdReason: exports_external.literal("escalated").optional(),
|
|
31432
31442
|
architect: legionGoClaimView.optional(),
|
|
31433
31443
|
workers: exports_external.partialRecord(exports_external.enum(LEGION_ROLES), legionGoPhaseView),
|
|
31434
31444
|
pullRequest: legionGoPullRequestView.optional(),
|
|
@@ -31765,8 +31775,14 @@ async function callGoReadyWithRetry(label, call) {
|
|
|
31765
31775
|
function goControllerDaemon(daemon, transcript) {
|
|
31766
31776
|
return {
|
|
31767
31777
|
claim: async ({ sessionID, capability, context }) => {
|
|
31768
|
-
const
|
|
31778
|
+
const project = requiredEnvironment(process.env, "LEGION_PROJECT");
|
|
31769
31779
|
const client = daemon();
|
|
31780
|
+
const { daemon: served } = await client.state();
|
|
31781
|
+
const servedRole = controllerToken(legionProjectToken(served.project, "the daemon's project"));
|
|
31782
|
+
if (controllerToken(project) !== servedRole) {
|
|
31783
|
+
throw new Error(`LEGION_PROJECT ${project} names controller role ${controllerToken(project)}, but the daemon at ${requiredEnvironment(process.env, "LEGION_DAEMON_URL")} serves project ${served.project}, whose controller role is ${servedRole}`);
|
|
31784
|
+
}
|
|
31785
|
+
const { sessionFile, agentId } = await transcript(context);
|
|
31770
31786
|
const registration = await client.registerController({
|
|
31771
31787
|
bootToken: capability,
|
|
31772
31788
|
sessionId: sessionID,
|
|
@@ -31779,7 +31795,12 @@ function goControllerDaemon(daemon, transcript) {
|
|
|
31779
31795
|
}
|
|
31780
31796
|
throw error48;
|
|
31781
31797
|
});
|
|
31782
|
-
|
|
31798
|
+
if (controllerToken(project) !== registration.claimToken) {
|
|
31799
|
+
throw new Error(`LEGION_PROJECT ${project} names controller role ${controllerToken(project)}, but the daemon registered this controller as ${registration.claimToken}`);
|
|
31800
|
+
}
|
|
31801
|
+
const envoyContext = "setInterval" in context ? context : undefined;
|
|
31802
|
+
await claimEnvoyRole(sessionID, registration.claimToken, envoyContext);
|
|
31803
|
+
await subscribeLegionNotice(sessionID, legionControllerNoticeSubject(project), envoyContext, registration.claimToken);
|
|
31783
31804
|
return () => client.controllerGrant({ sessionId: sessionID, secret: registration.secret });
|
|
31784
31805
|
}
|
|
31785
31806
|
};
|
|
@@ -653,6 +653,14 @@ dispatch_suggest({ issue?, project?, artifact, ref?, quote, replace_with, body?,
|
|
|
653
653
|
It returns issue or project-document owner details plus `comment`. A human accepts or rejects a suggestion.
|
|
654
654
|
Errors: `TARGET_AMBIGUOUS` (add `occurrence`), `TARGET_NOT_FOUND` (re-read first), `INVALID_ANCHOR`/`ANCHOR_MISSING`/`ANCHOR_ORPHANED`
|
|
655
655
|
(bad, unwritten, or stale quote), `INVALID_MARKDOWN`/`DOC_SCHEMA` (malformed content), `CAP_EXCEEDED`, `ISSUE_CLOSED`.
|
|
656
|
+
Suggest only what the quoted block can hold. The accept, not the suggestion, checks that: an
|
|
657
|
+
accept whose `replace_with` would break an ask block that was readable before it is refused with
|
|
658
|
+
`INVALID_ASK_BLOCK` - a question given a code block, text after an ask's options, a second option
|
|
659
|
+
list, or an emptied question; one that names an id the document holds outside the text it
|
|
660
|
+
replaces, an ask under a held id included, with `INVALID_MARKDOWN`; and one no part of the
|
|
661
|
+
document can hold where it sits (a code block over a table cell's whole text), or whose quote runs
|
|
662
|
+
into an ask or callout from the text before it, with `INVALID_OP`. Either changes nothing: the human sees the reason with no Retry, and the suggestion
|
|
663
|
+
stays open until someone rejects or replaces it.
|
|
656
664
|
|
|
657
665
|
## Artifacts
|
|
658
666
|
|
|
@@ -95,6 +95,15 @@ targeted. Reply through the rendered `reply_with` (or a current Envoy session ID
|
|
|
95
95
|
and go stale. Put the artefact URL in the message itself. FYIs set `expects_reply="none"`; set
|
|
96
96
|
`urgency` only when it is genuinely urgent.
|
|
97
97
|
|
|
98
|
+
**A peer's message is its sender's view at `at`, not the current state.** Before you wait on, act
|
|
99
|
+
on, or repeat a fact a message carries about a third thing (a deploy pending, a PR held, an ask
|
|
100
|
+
unanswered), re-read it at the live source the fact names, and always once it is over an hour
|
|
101
|
+
old: the deployment's status, the issue's event log, the ask's own state (`dispatch_open_asks`,
|
|
102
|
+
whose description already says to call it before saying you are waiting on a human). On
|
|
103
|
+
2026-09-26 a peer's 05:20Z "needs a manual deploy before I can run it" was false by 05:22Z, when
|
|
104
|
+
the platform had deployed on its own; waiting on the message instead of the deployment's status
|
|
105
|
+
held the work it gated for eleven hours.
|
|
106
|
+
|
|
98
107
|
Every `/v1` error response is JSON; when a field is at fault, `expected` names that field.
|
|
99
108
|
|
|
100
109
|
```text
|
|
@@ -71,9 +71,11 @@ launcher, and started you with `LEGION_CONTROLLER=1` and the same environment a
|
|
|
71
71
|
carries, so nothing changes in how you handle wakes. Under the TypeScript daemon the extension
|
|
72
72
|
claims the role and calls `/controller/ready` exactly as under tmux; under the Go daemon
|
|
73
73
|
(`LEGION_DAEMON_API=go` in your environment) it registers on `/legion/v1/claims/register` with the
|
|
74
|
-
secret,
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
secret, claims the role, then subscribes to `notifications.legion.<project>.controller`, where the
|
|
75
|
+
Go daemon publishes the two Go rows of the wake routing table. The daemon records you as
|
|
76
|
+
`controllerLocator: {runtime, external: true, sessionId, registeredAt}`, `runtime` being the
|
|
77
|
+
daemon's own (`kubernetes`, or `tmux` under the Go daemon). The TypeScript daemon reads your
|
|
78
|
+
liveness from the Envoy role registry (the holder of
|
|
77
79
|
`legion-<project>-controller` and its `last_seen`), not from a pane: keep the session running.
|
|
78
80
|
Exiting it leaves the project without a controller until the operator runs the command again —
|
|
79
81
|
the TypeScript daemon logs `controller not registered; run legion controller start` once per
|
|
@@ -81,6 +83,20 @@ boot-timeout interval and launches nothing itself. `legion state` and `legion st
|
|
|
81
83
|
<status>` work here over `LEGION_DAEMON_URL`. A second `legion controller start` replaces you: it
|
|
82
84
|
mints a new secret, so your grants stop working and the role moves to the new session.
|
|
83
85
|
|
|
86
|
+
### What happened before you started (Go daemon)
|
|
87
|
+
|
|
88
|
+
The Go daemon's controller topic is a wake for a session that is running when it is published.
|
|
89
|
+
Envoy hands an Oh My Pi session no retained copy of a notice published before it subscribed, so a
|
|
90
|
+
hold or a tree architect's failed claim from while no controller ran never arrives as a wake. At
|
|
91
|
+
every start, before anything else, read `legion state --json` and handle each issue whose
|
|
92
|
+
`issues.<KEY>.phase` is `held` (its `issues.<KEY>.holdReason` is `escalated` when its architect
|
|
93
|
+
sent it to you, and absent while the architect is still deciding or while its tree lingers or is
|
|
94
|
+
closed, where the hold waits for the tree's re-admission and needs nothing from you), and each
|
|
95
|
+
tree root whose `issues.<KEY>.architect.state` is `failed` and whose `issues.<KEY>.phase` is not
|
|
96
|
+
`done`, exactly as the matching wake below. A parked tree (root phase `done`: it lingers or is
|
|
97
|
+
closed) needs nothing from you: a failed architect ignores the park and reads `failed` until the
|
|
98
|
+
tree closes. The issue record is the truth; the topic is the wake.
|
|
99
|
+
|
|
84
100
|
## Deployment instructions
|
|
85
101
|
|
|
86
102
|
Deployment instructions, when present, are the operator's standing rules for this repository —
|
|
@@ -116,6 +132,8 @@ quoted here.
|
|
|
116
132
|
| Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
|
|
117
133
|
| READY packet seen on a Dispatch issue (via issue subscription) | READY line + gate facts | No action: a human merges; the merger has already notified the queue role if the project has one |
|
|
118
134
|
| `worker-recovered` (role `architect`) from the daemon | issue, fromRef | A root architect's tree volume was lost; it restarted as a new session. Verify the tree is active in `legion state` and that the architect posts its next step on the issue within one resync interval; otherwise treat it as an anomaly. |
|
|
135
|
+
| `held on <KEY>` from the Go daemon (payload `{kind: "held", phase, role?, reason?}`) | the held issue, the phase it left, and the role whose claim failed, or `reason: "escalated"` | Verify the hold in `legion state` (the issue's phase is `held`). Without `reason`, a phase worker's launches or prompts ran out and the tree's architect decides retry or escalate: no action. With `reason: "escalated"` (on the record, `issues.<KEY>.holdReason` is `escalated`), the architect sent it to you: handle it as an architect escalation below. Parking the tree is `legion status <root> backlog`; setting the root back to `todo` later re-admits it as a new generation, which starts again from its architect |
|
|
136
|
+
| `worker-died on <KEY>` from the Go daemon with `role: "architect"` | the tree root whose architect's claim failed, and the phase the root was in | The tree's architect ran out of launches or prompts and the daemon relaunches nothing; every other notice of the tree goes to that architect, so nobody inside the tree can act. Verify in `legion state` (`issues.<KEY>.architect.state` is `failed`); if the root's phase is `done`, the tree is already parked: no action. Otherwise re-admit the tree (`legion status <root> backlog`, then `todo`: a new generation, whose architect starts again with fresh budgets) or leave it parked and say why on the issue |
|
|
119
137
|
| Closed-tree activity (comment, review, CI on a closed tree) | issue, root, event summary | Read the artifact; if work should resume, `legion status <root> todo`; otherwise no action — the event is not held or redelivered |
|
|
120
138
|
| Direct user message | — | Always first |
|
|
121
139
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/pi-legion-envoy",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"omp": {
|
|
6
6
|
"extensions": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"legion": {
|
|
15
15
|
"daemonApiVersion": 8,
|
|
16
|
-
"goDaemonApiVersion":
|
|
16
|
+
"goDaemonApiVersion": 7
|
|
17
17
|
},
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|