blun-king-cli 9.1.588 → 9.1.594
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/CHANGELOG.md +37 -1
- package/README.md +12 -0
- package/agent-spine-plugin/CHANGELOG.md +1 -0
- package/agent-spine-plugin/docs/host-integration.md +15 -1
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install-hook.js +6 -5
- package/agent-spine-plugin/scripts/check-install-king.js +37 -0
- package/agent-spine-plugin/scripts/check-install.js +6 -1
- package/agent-spine-plugin/scripts/release-check.js +3 -2
- package/agent-spine-plugin/src/cli-agent.js +20 -1
- package/agent-spine-plugin/src/cli.js +1 -0
- package/agent-spine-plugin/src/index.js +1 -1
- package/agent-spine-plugin/src/lib/delivery-command-actions.js +16 -7
- package/agent-spine-plugin/src/lib/gateway-control.js +69 -1
- package/agent-spine-plugin/src/lib/gateway-host-fencing.js +92 -0
- package/agent-spine-plugin/src/lib/gateway-host-lifecycle.js +9 -1
- package/agent-spine-plugin/src/lib/gateway-prepared-host.js +28 -0
- package/agent-spine-plugin/src/lib/gateway-runs.js +46 -10
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1 -1
- package/agent-spine-plugin/src/lib/gateway-state.js +3 -1
- package/agent-spine-plugin/src/lib/hook-context.js +8 -3
- package/agent-spine-plugin/src/lib/hook-output.js +2 -3
- package/agent-spine-plugin/src/lib/hook-process-advisory.js +1 -2
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +17 -0
- package/agent-spine-plugin/src/lib/preflight.js +5 -19
- package/agent-spine-plugin/src/lib/source-roots.js +3 -2
- package/agent-spine-plugin/src/worker.js +30 -12
- package/bin/agentspine-king-goal-inbox.mjs +127 -0
- package/bin/agentspine-king-goal-intake.mjs +106 -0
- package/bin/agentspine-king-host-runner.mjs +109 -0
- package/bin/agentspine-king-snapshot-policy.cjs +80 -0
- package/bin/agentspine-king-status-policy.mjs +226 -0
- package/bin/agentspine-king-worker-host.mjs +160 -0
- package/bin/core-bootstrap.js +20 -3
- package/bin/launcher-mode.js +38 -1
- package/bin/launcher-restart-policy.cjs +131 -0
- package/bin/launcher-runtime.js +48 -9
- package/bin/runtime-exit-ledger.cjs +144 -0
- package/bin/runtime-exit-ledger.d.cts +23 -0
- package/bin/update-notice.js +37 -3
- package/bin/windows-node-crash-dump.cjs +289 -0
- package/blun.mjs +82560 -74204
- package/bundled-agent-sources.json +109 -34
- package/codebase-index/codebase_index.py +470 -0
- package/package.json +6 -1
- package/telegram-plugin/dist/bridge.mjs +390 -4
- package/worker-host.mjs +347384 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
# BLUN King Changelog
|
|
2
2
|
|
|
3
|
-
## 9.1.
|
|
3
|
+
## 9.1.594
|
|
4
|
+
|
|
5
|
+
- Preserve private Telegram replies while group messages wait, and retain slash-command routing.
|
|
6
|
+
- Honor long Retry-After delays and restore remaining cooldowns after resuming a session.
|
|
7
|
+
- Continue one settled tool step after rejected narration without publishing that narration or weakening final-answer review.
|
|
8
|
+
- Restore bounded write-continuation recovery, shell timeouts, task controls and session recovery.
|
|
9
|
+
- Keep input, quota rows, activity, task expansion and source-labelled tool output visible during streaming and history navigation.
|
|
10
|
+
- Require complete console regression evidence before packaging and verify matching public update metadata.
|
|
11
|
+
|
|
12
|
+
## 9.1.593
|
|
13
|
+
|
|
14
|
+
- Keep the input and both status rows visible after delayed startup messages, without clearing terminal scrollback.
|
|
15
|
+
|
|
16
|
+
## 9.1.592
|
|
17
|
+
|
|
18
|
+
- Preserve explicit startup permissions and remember deliberate permission selections for new sessions.
|
|
19
|
+
- Prevent overlapping session selections from closing the session being resumed.
|
|
20
|
+
- Restore rotating activity words, cumulative usage, step number and local date/time alongside live timing and token estimates.
|
|
21
|
+
- Allow protected-source reads redirected to literal null sinks without removing protected-write checks.
|
|
22
|
+
- Include the bounded startup and reload corrections prepared in 9.1.591.
|
|
23
|
+
|
|
24
|
+
## 9.1.591
|
|
25
|
+
|
|
26
|
+
- Finish the managed model refresh before opening a session, with a bounded catalog request.
|
|
27
|
+
- Reopen continued sessions through one reload so an overdue loop cannot race a second reload.
|
|
28
|
+
- Preserve additional workspace directories through the reload path.
|
|
29
|
+
- Keep the existing input, footer, quota rows, loop countdown and compaction display unchanged.
|
|
30
|
+
|
|
31
|
+
## 9.1.590
|
|
32
|
+
|
|
33
|
+
- Retain complete bounded instructions without the extra 8 KiB AgentSpine rejection.
|
|
34
|
+
- Keep routine AgentSpine preparation out of the visible transcript while retaining source and receipt checks.
|
|
35
|
+
- Preserve the active King profile when unrelated host settings are inherited.
|
|
36
|
+
- Restore the loop countdown and replace the previous recurring loop when scheduling a new one.
|
|
37
|
+
- Retain the approved compaction display and queued reload behavior.
|
|
38
|
+
|
|
39
|
+
## 9.1.589
|
|
4
40
|
|
|
5
41
|
- Bundle reviewed AgentSpine and Translate Native sources with verified update manifests.
|
|
6
42
|
- Preserve the active King profile when loading agent context.
|
package/README.md
CHANGED
|
@@ -60,3 +60,15 @@ Sie stehen unter `blun` und `king` identisch zur Verfügung.
|
|
|
60
60
|
denselben abgesicherten Updater. Er bleibt im installierten Stable- oder
|
|
61
61
|
Next-Kanal, verhindert Rückstufungen und übergibt npm ausschließlich eine zuvor
|
|
62
62
|
aufgelöste exakte Paketversion.
|
|
63
|
+
|
|
64
|
+
## Absturzdiagnose unter Windows
|
|
65
|
+
|
|
66
|
+
Startfehler und unbehandelte Ausnahmen werden im begrenzten, bereinigten Protokoll `BLUN_HOME/diagnostics/runtime-exits.jsonl` erfasst. Die Diagnose startet keinen neuen Prozess und ändert beim Start keine Windows-Registrywerte.
|
|
67
|
+
|
|
68
|
+
Ausführliche Node-Berichte sind optional. Nur `BLUN_NODE_CRASH_REPORTS=1` aktiviert sie für den gestarteten Prozess. Die Berichte liegen unter `BLUN_HOME/diagnostics/crash-dumps`. Umgebungsvariablen und Netzwerkinformationen werden ausgeschlossen; Programmargumente, Dateipfade und weitere sensible Prozessdaten können trotzdem enthalten sein. Berichte vor einer Weitergabe prüfen.
|
|
69
|
+
|
|
70
|
+
Drei gemeinsam reservierte Plätze begrenzen die Anzahl dieser Node-Berichte, nicht deren Dateigröße. Ein ordentlich beendeter Prozess gibt seine Reservierung frei; ein späterer Prozess kann den Bericht dieses Platzes ersetzen. Aktive oder unklar verwaiste Reservierungen werden nicht automatisch entfernt. Sind alle Plätze belegt oder liegen alte Berichte mit Zufallskennung vor, bleiben neue Detailberichte aus, bis der Betreiber diese Dateien ausdrücklich geprüft und bereinigt hat. Der normale Start und das kleine Fehlerprotokoll bleiben davon unabhängig. Sitzungen und fremde Dateien werden nicht entfernt.
|
|
71
|
+
|
|
72
|
+
Native Windows-Dumps sind davon getrennt. Der mitgelieferte Helfer `bin/windows-node-crash-dump.cjs` bietet `--query-wer` für eine reine Abfrage. Eine Änderung erfordert ausdrücklich `--configure-wer --confirm-system-wide`, ein gesetztes `BLUN_HOME` und ausreichende Windows-Rechte. Sie betrifft alle Prozesse namens `node.exe`, nicht nur BLUN: drei Minidumps im angegebenen Diagnoseordner. Bei einem Teilfehler nennt die JSON-Ausgabe die bereits gesetzten Werte. Der normale Start ruft diesen Befehl niemals auf.
|
|
73
|
+
|
|
74
|
+
Node-Berichte erfassen unterstützte Node-Fehler; sie sind kein Beleg dafür, dass jeder native Windows-Abbruch erfasst wird. Die Funktion lässt den ursprünglichen Fehler und den vorgesehenen Prozessabbruch bestehen.
|
|
@@ -58,6 +58,7 @@ All notable changes to AgentSpine will be documented here. The project follows [
|
|
|
58
58
|
|
|
59
59
|
### Fixed
|
|
60
60
|
|
|
61
|
+
- King keeps complete instructions within the existing source-reader resource bounds, resolves its isolated profile first, and accounts for JSON escaping without adding an instruction-size gate. Routine hooks, lifecycle receipts and process advisories use quiet `additionalContext`; actual denials and exact source/receipt checks remain unchanged. Synthetic fresh/upgrade probes cover large and combined sources, escaping and byte preservation. Package allowance grows by 4 KiB unpacked for the helper and install checks; packed and file-count limits stay fixed. Live King/macOS acceptance remains separate.
|
|
61
62
|
- The portal/thread lifecycle probe that starts a fresh process now runs outside the shared Windows I/O pool. Its existing assertions and process deadlines are unchanged.
|
|
62
63
|
- The hermetic runner isolates the real host-origin source-attestation probe from concurrent Windows filesystem pressure, preserving its existing fail-closed result and timing boundaries without increasing a timeout.
|
|
63
64
|
- Reuse host-verified, assignment-bound briefing observations in MCP delivery preparation without a second content fetch; keep missing proof advisory and document provider-specific history limits.
|
|
@@ -10,7 +10,7 @@ Provider evidence is not interchangeable. Each adapter separately binds its life
|
|
|
10
10
|
|---|---|---|---|
|
|
11
11
|
| Project instructions | `CLAUDE.md` hierarchy | `AGENTS.md` hierarchy | `AGENTS.md` through the isolated BLUN profile |
|
|
12
12
|
| Hook package | Native `hooks/hooks.json` | Explicit `hooks/codex.json` | Manifest lifecycle hooks |
|
|
13
|
-
| Verified hook briefing → delivery knowledge | Native `additionalContext` handoff receipt | Native `additionalContext` handoff receipt |
|
|
13
|
+
| Verified hook briefing → delivery knowledge | Native `additionalContext` handoff receipt | Native `additionalContext` handoff receipt | Bounded model-only `additionalContext` handoff receipt; live acceptance open |
|
|
14
14
|
| Historical transcript enrollment | Bounded registered Claude source | Bounded Codex `sessions` source with verified headers | Protected King agent-wire mapping; live mapping open |
|
|
15
15
|
| Cross-provider evidence handoff | Explicit opt-in; Claude provenance retained | Explicit opt-in; Codex provenance retained | Same contract; live launcher unverified |
|
|
16
16
|
| Native permissions/trust | Host-owned | Host-owned | Host-owned; enforcement of returned block decisions remains externally unverified |
|
|
@@ -19,6 +19,20 @@ Provider evidence is not interchangeable. Each adapter separately binds its life
|
|
|
19
19
|
|
|
20
20
|
Adapter acceptance uses bounded synthetic A/B sessions and covers restart, source mutation, foreign scope, private exclusion and service failure. Cross-provider recall still needs local opt-in plus `includePriorProviders: true` with `includePriorSessions: true`. Report repository, installed-host and model observations separately.
|
|
21
21
|
|
|
22
|
+
King preparation, lifecycle receipts and process advisories do not emit visible
|
|
23
|
+
`message` prompts. Required instructions remain byte-exact; `UserPromptSubmit`
|
|
24
|
+
uses model-only `additionalContext`. Other events also return that quiet field;
|
|
25
|
+
whether the host consumes it is a separate lifecycle contract. Actual deny/block
|
|
26
|
+
decisions retain their original error schema.
|
|
27
|
+
King uses the existing source-reader safety bounds of 4 MiB per document and
|
|
28
|
+
8 MiB for the source set, not Codex's 32 KiB instruction cap. When both King
|
|
29
|
+
environment bindings are present, `BLUN_HOME` owns the profile even if an unrelated
|
|
30
|
+
`CODEX_HOME` is inherited. These are resource limits, not recommended file sizes.
|
|
31
|
+
Codex/King internal hook envelopes account for the serialized verified briefing,
|
|
32
|
+
including JSON escaping. King's emitted runtime context remains at most 1200
|
|
33
|
+
bytes; its native instruction loader separately supplies full instructions.
|
|
34
|
+
Claude's existing instruction and context limits remain unchanged.
|
|
35
|
+
|
|
22
36
|
## Claude Code
|
|
23
37
|
|
|
24
38
|
| Component | Path | Purpose |
|
|
@@ -6,7 +6,7 @@ The receipt binds agent and optional persona, user, tenant, host, instruction ho
|
|
|
6
6
|
|
|
7
7
|
## Host instructions
|
|
8
8
|
|
|
9
|
-
Claude Code uses its resolved user and project `CLAUDE.md` hierarchy. Codex uses the corresponding `AGENTS.override.md`/`AGENTS.md` hierarchy. A generic host must explicitly bind `instruction_host` to `claude` or `codex`; AgentSpine does not guess. The mandatory preflight section contains the complete bytes of every active instruction document.
|
|
9
|
+
Claude Code uses its resolved user and project `CLAUDE.md` hierarchy. Codex uses the corresponding `AGENTS.override.md`/`AGENTS.md` hierarchy. A generic host must explicitly bind `instruction_host` to `claude` or `codex`; AgentSpine does not guess. The mandatory preflight section contains the complete bytes of every active instruction document. The standard budget is 8 KiB, with aggregate overflow up to 16 KiB for Claude or 32 KiB for Codex. King uses its own profile and the existing source-reader resource limits: 4 MiB per source and 8 MiB total, not a recommended instruction size (see [host integration](host-integration.md)). The mode, used bytes, overflow and hard limit are bound into the signed exact-turn receipt and revalidated before consumption. An unreadable, replaced, deleted, resource-oversized, out-of-scope, or symlinked mandatory file blocks immediately with a visible bounded diagnostic instead of degrading to a descriptor or waiting silently.
|
|
10
10
|
|
|
11
11
|
Claude Code's `InstructionsLoaded` lifecycle event is registered as an additional observability signal, while `UserPromptSubmit` remains the blocking and injection boundary. Codex uses its own manifest-selected hook set without that unsupported Claude-only event. The preflight does not rely on the model remembering to read a file or call a tool.
|
|
12
12
|
|
|
@@ -101,6 +101,7 @@ export async function invokeInstalledHook(pluginRoot, projectRoot, stateRoot, ho
|
|
|
101
101
|
decision: protocol.decision || null,
|
|
102
102
|
reason: protocol.reason || null,
|
|
103
103
|
message: protocol.hookSpecificOutput?.message || null,
|
|
104
|
+
additionalContext: protocol.hookSpecificOutput?.additionalContext || null,
|
|
104
105
|
...protocolShape(protocol)
|
|
105
106
|
});
|
|
106
107
|
} catch (error) {
|
|
@@ -122,7 +123,7 @@ export async function invokeInstalledBlunPostWriteDigest(pluginRoot, projectRoot
|
|
|
122
123
|
};
|
|
123
124
|
const prompted = await invokeInstalledHook(pluginRoot, projectRoot, stateRoot, "blun", shared,
|
|
124
125
|
{ requireBriefing: false });
|
|
125
|
-
const requirementId = prompted.
|
|
126
|
+
const requirementId = prompted.additionalContext?.match(
|
|
126
127
|
/Requirement: (premortem-requirement:[a-f0-9]{64}:[a-f0-9]{64})/
|
|
127
128
|
)?.[1];
|
|
128
129
|
if (!requirementId) throw new Error("installed BLUN prompt did not expose its premortem requirement");
|
|
@@ -163,12 +164,12 @@ export async function invokeInstalledBlunPostWriteDigest(pluginRoot, projectRoot
|
|
|
163
164
|
const post = await invokeInstalledHook(pluginRoot, projectRoot, stateRoot, "blun", {
|
|
164
165
|
...shared, ...tool, hook_event_name: "PostToolUse", success: true, tool_response: { ok: true }
|
|
165
166
|
}, { requireBriefing: false });
|
|
166
|
-
const digest = post.
|
|
167
|
+
const digest = post.additionalContext?.match(/Premortem latest write sha256 ([a-f0-9]{64})/)?.[1];
|
|
167
168
|
if (!digest || !exactKeys(post.protocolKeys, ["hookSpecificOutput"])
|
|
168
|
-
|| !exactKeys(post.hookSpecificKeys, ["
|
|
169
|
-
throw new Error("installed BLUN PostToolUse did not return its latest-write digest through
|
|
169
|
+
|| !exactKeys(post.hookSpecificKeys, ["additionalContext", "hookEventName"])) {
|
|
170
|
+
throw new Error("installed BLUN PostToolUse did not return its latest-write digest through model context");
|
|
170
171
|
}
|
|
171
|
-
return { contextField: "
|
|
172
|
+
return { contextField: "additionalContext", latestWriteDigest: digest };
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
function exactKeys(value, expected) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { invokeInstalledHook } from "./check-install-hook.js";
|
|
4
|
+
|
|
5
|
+
export async function invokeInstalledKingInstructions(pluginRoot, workspace) {
|
|
6
|
+
const results = [];
|
|
7
|
+
for (const [name, projectBytes, userBytes, fill] of [
|
|
8
|
+
["large", 44000, 0, "x"], ["combined", 25000, 19000, "x"], ["escaped", 32768, 0, '"']
|
|
9
|
+
]) {
|
|
10
|
+
const root = join(workspace, name, "project"), home = join(workspace, name, "home");
|
|
11
|
+
await mkdir(join(root, ".git"), { recursive: true });
|
|
12
|
+
await mkdir(home, { recursive: true });
|
|
13
|
+
const sources = new Map();
|
|
14
|
+
for (const [path, bytes] of [[join(root, "AGENTS.md"), projectBytes], [join(home, "AGENTS.md"), userBytes]]) {
|
|
15
|
+
if (!bytes) continue;
|
|
16
|
+
const header = "# Synthetic installed King instructions\n\n";
|
|
17
|
+
const content = Buffer.from(header + fill.repeat(bytes - Buffer.byteLength(header)));
|
|
18
|
+
await writeFile(path, content); sources.set(path, content);
|
|
19
|
+
}
|
|
20
|
+
const output = await invokeInstalledHook(pluginRoot, root, join(workspace, name, "state"), "blun", {
|
|
21
|
+
hook_event_name: "UserPromptSubmit", host: "codex", cwd: root,
|
|
22
|
+
session_id: `session:king-${name}`, event_id: `turn:king-${name}`,
|
|
23
|
+
prompt: "Inspect the complete synthetic instructions."
|
|
24
|
+
}, { requireBriefing: false, extraEnv: { BLUN_HOME: home, CODEX_HOME: home } });
|
|
25
|
+
const bytes = Buffer.byteLength(output.additionalContext || "");
|
|
26
|
+
if (output.decision || output.message || !bytes || bytes > 1200
|
|
27
|
+
|| JSON.stringify(output.hookSpecificKeys) !== JSON.stringify(["additionalContext", "hookEventName"])) {
|
|
28
|
+
throw new Error(`installed King ${name} must accept full instructions through quiet bounded context: ${output.reason}`);
|
|
29
|
+
}
|
|
30
|
+
for (const [path, original] of sources) {
|
|
31
|
+
if (!(await readFile(path)).equals(original)) throw new Error("installed King changed source instructions");
|
|
32
|
+
}
|
|
33
|
+
results.push({ name, instructionBytes: projectBytes + userBytes, contextBytes: bytes,
|
|
34
|
+
contextField: "additionalContext", sourcePreserved: true });
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
invokeInstalledBlockingProtocols, invokeInstalledBlunPostWriteDigest, invokeInstalledHook
|
|
13
13
|
} from "./check-install-hook.js";
|
|
14
14
|
import { invokeInstalledSelfstarter as runInstalledSelfstarter } from "./check-install-selfstarter.js";
|
|
15
|
+
import { invokeInstalledKingInstructions } from "./check-install-king.js";
|
|
15
16
|
|
|
16
17
|
function hash(value) {
|
|
17
18
|
return createHash("sha256").update(value).digest("hex");
|
|
@@ -364,13 +365,15 @@ export async function checkInstall(root = process.cwd()) {
|
|
|
364
365
|
await mkdir(userProject, { recursive: true });
|
|
365
366
|
await writeFile(source, "# Existing soul\n\nNever modify me.\n", "utf8");
|
|
366
367
|
await writeFile(join(userProject, "CLAUDE.md"), "# Installed Claude rules\n\nLoad this before every answer.\n", "utf8");
|
|
367
|
-
|
|
368
|
+
const largeCodexRules = `# Installed Codex rules\n\n${"x".repeat(17_590 - 25)}`;
|
|
369
|
+
await writeFile(join(userProject, "AGENTS.md"), largeCodexRules, "utf8");
|
|
368
370
|
const protectedInstallSources = [source, join(userProject, "CLAUDE.md"), join(userProject, "AGENTS.md")];
|
|
369
371
|
const sourceHashes = new Map(await Promise.all(protectedInstallSources.map(async (path) => [path, hash(await readFile(path))])));
|
|
370
372
|
|
|
371
373
|
const fresh = join(workspace, "fresh", "agent-spine");
|
|
372
374
|
await copyBundle(root, fresh);
|
|
373
375
|
const freshResult = await checkHosts(fresh);
|
|
376
|
+
const freshKing = await invokeInstalledKingInstructions(fresh, join(workspace, "king-fresh"));
|
|
374
377
|
const aliasRoot = join(workspace, "fresh-alias");
|
|
375
378
|
await symlink(join(workspace, "fresh"), aliasRoot, process.platform === "win32" ? "junction" : "dir");
|
|
376
379
|
const aliasResult = await checkHosts(join(aliasRoot, "agent-spine"));
|
|
@@ -410,6 +413,7 @@ export async function checkInstall(root = process.cwd()) {
|
|
|
410
413
|
await removeTree(installed);
|
|
411
414
|
await rename(staging, installed);
|
|
412
415
|
const upgraded = await checkHosts(installed);
|
|
416
|
+
const upgradedKing = await invokeInstalledKingInstructions(installed, join(workspace, "king-upgrade"));
|
|
413
417
|
const upgradeState = join(workspace, "state-upgrade");
|
|
414
418
|
const upgradedHook = await invokeInstalledHook(installed, userProject, upgradeState, "codex");
|
|
415
419
|
const upgradedBlockingProtocols = await invokeInstalledBlockingProtocols(
|
|
@@ -439,6 +443,7 @@ export async function checkInstall(root = process.cwd()) {
|
|
|
439
443
|
automaticBriefing: { fresh: freshHook, upgrade: upgradedHook },
|
|
440
444
|
blockingProtocols: { fresh: freshBlockingProtocols, upgrade: upgradedBlockingProtocols },
|
|
441
445
|
blunPostWriteDigest: { fresh: freshBlunPost, upgrade: upgradedBlunPost },
|
|
446
|
+
kingInstructions: { fresh: freshKing, upgrade: upgradedKing },
|
|
442
447
|
automaticAttention: { fresh: freshAttention, upgrade: upgradedAttention },
|
|
443
448
|
automaticSelfstarter: { fresh: freshSelfstarter, upgrade: upgradedSelfstarter },
|
|
444
449
|
automaticChannelWake: { fresh: freshChannelWake, upgrade: upgradedChannelWake },
|
|
@@ -8,7 +8,8 @@ const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?
|
|
|
8
8
|
// Includes the bounded user-feedback candidate path; this is not semantic acceptance.
|
|
9
9
|
// Measured code growth before documentation: 1874 packed / 7830 unpacked bytes.
|
|
10
10
|
const MAX_PACKED_BYTES = 544 * 1024;
|
|
11
|
-
|
|
11
|
+
// King instruction/handoff fix and copied-install probes add about 4 KiB unpacked.
|
|
12
|
+
const MAX_UNPACKED_BYTES = 2434 * 1024;
|
|
12
13
|
const REQUIRED_PACKAGE_FILES = [
|
|
13
14
|
"blun.plugin.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".mcp.json",
|
|
14
15
|
"CHANGELOG.md", "LICENSE", "README.md", "bin/agentspine.js", "bin/agentspine-mcp.js",
|
|
@@ -78,7 +79,7 @@ function validatePackageReport(report, version) {
|
|
|
78
79
|
assert(typeof item.integrity === "string" && item.integrity.startsWith("sha512-"), "npm pack did not report SHA-512 integrity");
|
|
79
80
|
assert(Number.isInteger(item.entryCount) && item.entryCount > 0 && item.entryCount <= 500, "npm package file count is outside the release limit");
|
|
80
81
|
assert(Number.isInteger(item.size) && item.size > 0 && item.size <= MAX_PACKED_BYTES, "npm package exceeds the 544 KiB packed release limit");
|
|
81
|
-
assert(Number.isInteger(item.unpackedSize) && item.unpackedSize > 0 && item.unpackedSize <= MAX_UNPACKED_BYTES, "npm package exceeds the
|
|
82
|
+
assert(Number.isInteger(item.unpackedSize) && item.unpackedSize > 0 && item.unpackedSize <= MAX_UNPACKED_BYTES, "npm package exceeds the 2434 KiB unpacked release limit");
|
|
82
83
|
const paths = (item.files || []).map((file) => file.path);
|
|
83
84
|
assert(paths.length === new Set(paths).size, "npm package contains duplicate paths");
|
|
84
85
|
for (const path of REQUIRED_PACKAGE_FILES) assert(paths.includes(path), `npm package is missing required file: ${path}`);
|
|
@@ -2,7 +2,7 @@ import { checkDelegation, createTask, deleteTask, grantDelegation, loadDelegatio
|
|
|
2
2
|
import { cancelJob, deleteJob, grantExecution, loadExecutionPolicy, registerJob, revokeExecution, selfstarterContext } from "./lib/selfstarter.js";
|
|
3
3
|
import { channelRuntimeContext, grantChannelBinding, loadChannelPolicy, revokeChannelBinding } from "./lib/channel-runtime.js";
|
|
4
4
|
import { personaContext, syncPersonaRosterFromEnvironment } from "./lib/persona-runtime.js";
|
|
5
|
-
import { assignGoal, gatewayContext, resolveGoalKnowledgeGap, setGatewayControl } from "./lib/gateway-runtime.js";
|
|
5
|
+
import { assignGoal, gatewayContext, resolveGoalKnowledgeGap, reviewLegacyGatewayHold, setGatewayControl } from "./lib/gateway-runtime.js";
|
|
6
6
|
import {
|
|
7
7
|
currentHostTranscriptReceipt, enrollPrivateSessionTimeline, LOCAL_TIMELINE_ENROLLMENT_CONFIRMATION,
|
|
8
8
|
LOCAL_TIMELINE_ENROLLMENT_RECOVERY_CONFIRMATION, resetPrivateSessionTimelineEnrollment
|
|
@@ -35,6 +35,7 @@ export const agentCommands = new Set([
|
|
|
35
35
|
"goal-assign",
|
|
36
36
|
"goal-clarify",
|
|
37
37
|
"gateway-control",
|
|
38
|
+
"gateway-review-legacy-hold",
|
|
38
39
|
"gateway-status",
|
|
39
40
|
"timeline-receipt",
|
|
40
41
|
"timeline-enroll",
|
|
@@ -293,4 +294,22 @@ export async function runAgentCommand({ command, flags, positional, json }) {
|
|
|
293
294
|
if (command === "gateway-status") {
|
|
294
295
|
return output(await gatewayContext({ root: positional[0] || process.cwd(), agentId: flags.agent || null }), json);
|
|
295
296
|
}
|
|
297
|
+
|
|
298
|
+
if (command === "gateway-review-legacy-hold") {
|
|
299
|
+
const required = ["root", "goal", "owner", "project", "worker", "claimed-at", "attempt", "review-note"];
|
|
300
|
+
const allowed = new Set([...required, "group", "host-stopped", "effects-reviewed", "confirm-local-review", "json"]);
|
|
301
|
+
if (positional.length !== 1 || Object.keys(flags).some((name) => !allowed.has(name))) {
|
|
302
|
+
throw new Error("legacy hold review accepts one queue ID and only its documented exact review flags");
|
|
303
|
+
}
|
|
304
|
+
if (required.some((name) => typeof flags[name] !== "string" || !flags[name])) {
|
|
305
|
+
throw new Error("legacy hold review requires explicit root, goal, owner, project, worker, claimed-at, attempt and review-note");
|
|
306
|
+
}
|
|
307
|
+
return output(await reviewLegacyGatewayHold({
|
|
308
|
+
root: flags.root, queueId: positional[0], goalId: flags.goal, ownerSubjectId: flags.owner,
|
|
309
|
+
projectId: flags.project, groupId: flags.group ?? null, workerId: flags.worker,
|
|
310
|
+
claimedAt: flags["claimed-at"], attempt: Number(flags.attempt), reviewNote: flags["review-note"],
|
|
311
|
+
hostStopped: booleanFlag(flags["host-stopped"]), effectsReviewed: booleanFlag(flags["effects-reviewed"]),
|
|
312
|
+
confirmation: booleanFlag(flags["confirm-local-review"]) ? "local-owner-confirmed" : null
|
|
313
|
+
}), json);
|
|
314
|
+
}
|
|
296
315
|
}
|
|
@@ -115,6 +115,7 @@ Usage:
|
|
|
115
115
|
agentspine goal-clarify <goal-id> --gap id --answer text [--source owner-input|objective-observation] [--source-digest sha256] --confirm-local-goal
|
|
116
116
|
agentspine gateway-control [root] [--enabled true|false] [--kill-switch true|false] --confirm-local-gateway
|
|
117
117
|
agentspine gateway-status [root] [--agent id]
|
|
118
|
+
agentspine gateway-review-legacy-hold <queue-id> --root path --goal id --owner id --project id [--group id] --worker id --claimed-at ISO --attempt n --review-note text --host-stopped --effects-reviewed --confirm-local-review
|
|
118
119
|
agentspine share-keygen <signer-id> [--public-out signer.json] [--rotate] [--confirm-local-share]
|
|
119
120
|
agentspine share-signers [root]
|
|
120
121
|
agentspine share-trust <signer.json> [--root path] [--confirm-local-share]
|
|
@@ -48,7 +48,7 @@ export {
|
|
|
48
48
|
export {
|
|
49
49
|
EXECUTION_ATTEMPT_SCHEMA, GATEWAY_EVENT_SCHEMA, GATEWAY_POLICY_SCHEMA, GATEWAY_RUNTIME_SCHEMA,
|
|
50
50
|
GOAL_PLAN_SCHEMA,
|
|
51
|
-
assignGoal, claimGatewayWork, completeGatewayRun, deliverPrepared, failGatewayRun, markGatewayHostStarted,
|
|
51
|
+
assignGoal, claimGatewayWork, completeGatewayRun, deliverPrepared, failGatewayRun, markGatewayHostStarted, reviewLegacyGatewayHold,
|
|
52
52
|
enqueueGatewayWake, executionAttemptForStep, gatewayContext, gatewayRuntimeFindings,
|
|
53
53
|
inspectGatewayRuntime, loadGatewayRuntime, reconcileGateway, resolveGoalKnowledgeGap, setGatewayControl
|
|
54
54
|
} from "./lib/gateway-runtime.js";
|
|
@@ -298,7 +298,7 @@ function segmentWrites(segment) {
|
|
|
298
298
|
if (executable === "dd") return tokens.slice(1).some((token) => /^of=/.test(token.value));
|
|
299
299
|
if (new Set(["bash", "sh", "zsh", "pwsh", "powershell"]).has(executable)
|
|
300
300
|
&& new Set(["-c", "-command"]).has(tokens[1]?.value.toLowerCase()) && tokens[2]) {
|
|
301
|
-
return commandWriteActions(tokens[2].value).length > 0;
|
|
301
|
+
return commandWriteActions(tokens[2].value, 0, executable === "pwsh" || executable === "powershell").length > 0;
|
|
302
302
|
}
|
|
303
303
|
if (executable === "eval" && tokens[1]) {
|
|
304
304
|
return commandWriteActions(tokens.slice(1).map((token) => token.value).join(" ")).length > 0;
|
|
@@ -378,7 +378,7 @@ function structuredComparisonIndexes(command) {
|
|
|
378
378
|
return indexes;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
function redirectionActions(command) {
|
|
381
|
+
function redirectionActions(command, powershell = false) {
|
|
382
382
|
const actions = [];
|
|
383
383
|
const comparisons = structuredComparisonIndexes(command);
|
|
384
384
|
let quote = null;
|
|
@@ -405,26 +405,32 @@ function redirectionActions(command) {
|
|
|
405
405
|
if (next === ">") index += 1;
|
|
406
406
|
let target = index + 1;
|
|
407
407
|
while (/\s/.test(command[target] || "")) target += 1;
|
|
408
|
+
const tail = shellSegments(command.slice(target))[0]?.text || "";
|
|
409
|
+
const sink = shellTokens(tail)[0];
|
|
410
|
+
const nullDevice = ((!powershell || process.platform !== "win32") && sink?.value === "/dev/null")
|
|
411
|
+
|| (process.platform === "win32" && /^nul$/i.test(sink?.value || ""));
|
|
412
|
+
const nullVariable = powershell && sink?.plain && /^\$null$/i.test(sink.value);
|
|
413
|
+
if (nullDevice || nullVariable) continue;
|
|
408
414
|
if (!["&", "="].includes(command[target])) actions.push({ kind: "write", family: null, index });
|
|
409
415
|
}
|
|
410
416
|
return actions;
|
|
411
417
|
}
|
|
412
418
|
|
|
413
|
-
function commandWriteActions(command, depth = 0) {
|
|
419
|
+
function commandWriteActions(command, depth = 0, powershell = false) {
|
|
414
420
|
const heredocs = maskHeredocBodies(command);
|
|
415
421
|
const analyzable = heredocs.command;
|
|
416
422
|
const actions = shellSegments(analyzable).filter(segmentWrites)
|
|
417
423
|
.map((segment) => ({ kind: "write", family: null, index: segment.index }));
|
|
418
|
-
actions.push(...redirectionActions(analyzable));
|
|
424
|
+
actions.push(...redirectionActions(analyzable, powershell));
|
|
419
425
|
if (depth < MAX_SUBSTITUTION_DEPTH) {
|
|
420
426
|
for (const heredoc of heredocs.bodies) {
|
|
421
427
|
const executable = shellExecutableName(executableTokens(heredoc.opener)[0]?.value);
|
|
422
428
|
if (!SHELL_STDIN_EXECUTORS.has(executable)) continue;
|
|
423
|
-
actions.push(...commandWriteActions(heredoc.text, depth + 1)
|
|
429
|
+
actions.push(...commandWriteActions(heredoc.text, depth + 1, false)
|
|
424
430
|
.map((action) => ({ ...action, index: heredoc.index + action.index })));
|
|
425
431
|
}
|
|
426
432
|
for (const substitution of shellSubstitutions(analyzable, depth)) {
|
|
427
|
-
actions.push(...commandWriteActions(substitution.text, depth + 1)
|
|
433
|
+
actions.push(...commandWriteActions(substitution.text, depth + 1, powershell)
|
|
428
434
|
.map((action) => ({ ...action, index: substitution.index + action.index })));
|
|
429
435
|
}
|
|
430
436
|
}
|
|
@@ -446,7 +452,10 @@ export function deliveryToolActions(input) {
|
|
|
446
452
|
if (!SHELL_TOOLS.test(toolName)) return [];
|
|
447
453
|
const command = commandFromInput(input);
|
|
448
454
|
if (command.length > MAX_COMMAND_CHARS) return [];
|
|
449
|
-
const
|
|
455
|
+
const toolInput = input?.tool_input ?? input?.tool_args;
|
|
456
|
+
const powershell = /(^|__)(powershell|pwsh)(_|$)/i.test(toolName)
|
|
457
|
+
|| /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i.test(toolInput?.shell || "");
|
|
458
|
+
const actions = commandWriteActions(command, 0, powershell);
|
|
450
459
|
const test = trailingTestAction(command);
|
|
451
460
|
if (test) actions.push(test);
|
|
452
461
|
return actions.sort((left, right) => left.index - right.index || left.kind.localeCompare(right.kind));
|
|
@@ -2,6 +2,7 @@ import { attentionFindings, loadAttention } from "./attention.js";
|
|
|
2
2
|
import { channelRuntimeFindings, loadChannelPolicy, loadChannelRuntime } from "./channel-runtime.js";
|
|
3
3
|
import { loadGraph } from "./graph.js";
|
|
4
4
|
import { expireGatewayLane } from "./gateway-host-lifecycle.js";
|
|
5
|
+
import { heldExecution } from "./gateway-host-fencing.js";
|
|
5
6
|
import * as goalPremortem from "./gateway-premortem.js";
|
|
6
7
|
import { resolveKnowledgeGapCandidate, sameKnowledgeGapResolution } from "./knowledge-evidence.js";
|
|
7
8
|
import { loadPersonaRuntime, personaRuntimeFindings } from "./persona-runtime.js";
|
|
@@ -33,6 +34,71 @@ export async function setGatewayControl({ root = process.cwd(), enabled, killSwi
|
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export async function reviewLegacyGatewayHold({ root = process.cwd(), queueId, goalId, ownerSubjectId,
|
|
38
|
+
projectId, groupId = null, workerId, claimedAt, attempt, hostStopped, effectsReviewed, reviewNote,
|
|
39
|
+
confirmation, now = new Date() }) {
|
|
40
|
+
if (confirmation !== CONFIRMATION || hostStopped !== true || effectsReviewed !== true) {
|
|
41
|
+
throw new Error("legacy hold review requires explicit local owner confirmation of host stop and reviewed effects");
|
|
42
|
+
}
|
|
43
|
+
queueId = exactId(queueId, "queueId"); goalId = exactId(goalId, "goalId");
|
|
44
|
+
ownerSubjectId = exactId(ownerSubjectId, "ownerSubjectId"); workerId = exactId(workerId, "workerId");
|
|
45
|
+
projectId = exactId(projectId, "projectId"); groupId = exactId(groupId, "groupId", true);
|
|
46
|
+
if (typeof claimedAt !== "string" || timestamp(claimedAt) !== claimedAt
|
|
47
|
+
|| !Number.isInteger(attempt) || attempt < 1 || attempt > 20) {
|
|
48
|
+
throw new Error("legacy hold review requires the exact claim timestamp and integer attempt");
|
|
49
|
+
}
|
|
50
|
+
if (typeof reviewNote !== "string" || reviewNote.length > 500) throw new Error("legacy review note must contain 1-500 characters");
|
|
51
|
+
reviewNote = safeText(reviewNote, "reviewNote", 500);
|
|
52
|
+
const current = timestamp(now);
|
|
53
|
+
const paths = await pathsFor(root);
|
|
54
|
+
return withLock(paths, async () => {
|
|
55
|
+
const policy = await readJson(paths.gatewayPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy);
|
|
56
|
+
const runtime = await readJson(paths.gatewayRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime);
|
|
57
|
+
const item = runtime.queue.find((entry) => entry.queueId === queueId);
|
|
58
|
+
const goal = policy.goals.find((entry) => entry.goalId === goalId);
|
|
59
|
+
const step = goal?.plan?.steps.find((entry) => entry.stepId === item?.goalStepId);
|
|
60
|
+
if (!item || !goal || item.goalId !== goalId || goal.ownerSubjectId !== ownerSubjectId
|
|
61
|
+
|| item.projectId !== projectId || goal.projectId !== projectId
|
|
62
|
+
|| item.groupId !== groupId || goal.groupId !== groupId
|
|
63
|
+
|| (item.goalStepId && !step) || item.agentId !== planStepAgentId(goal, step)) {
|
|
64
|
+
throw new Error("legacy hold review requires the exact goal owner and queue scope");
|
|
65
|
+
}
|
|
66
|
+
const hold = item.executionHold;
|
|
67
|
+
if (!hold || hold.workerId !== workerId || hold.claimedAt !== claimedAt || hold.attempt !== attempt) {
|
|
68
|
+
throw new Error("legacy hold review requires the exact held generation");
|
|
69
|
+
}
|
|
70
|
+
if (hold.hostSessionId !== null || runtime.history.some((entry) => entry.kind === "queue"
|
|
71
|
+
&& entry.objectId === queueId && entry.value.attempts === attempt
|
|
72
|
+
&& entry.value.executionHold?.hostSessionId)) {
|
|
73
|
+
throw new Error("a known host session requires exact-session quiescence, not legacy owner review");
|
|
74
|
+
}
|
|
75
|
+
if (item.lease !== null || !["blocked", "cancelled"].includes(item.status)) {
|
|
76
|
+
throw new Error("legacy owner review requires an unleased blocked or cancelled queue item");
|
|
77
|
+
}
|
|
78
|
+
const details = { goalId, ownerSubjectId, projectId, groupId, workerId, claimedAt, attempt,
|
|
79
|
+
hostStopped: true, effectsReviewed: true, reviewNote, reviewBasis: "local-owner-attestation" };
|
|
80
|
+
if (hold.status === "settled") {
|
|
81
|
+
const receipt = runtime.receipts.find((entry) => entry.kind === "legacy-host-owner-reviewed"
|
|
82
|
+
&& entry.objectId === queueId && entry.details.workerId === workerId
|
|
83
|
+
&& entry.details.claimedAt === claimedAt && entry.details.attempt === attempt);
|
|
84
|
+
if (!receipt || JSON.stringify(receipt.details) !== JSON.stringify(details)) {
|
|
85
|
+
throw new Error("legacy hold already has a different settlement or owner review");
|
|
86
|
+
}
|
|
87
|
+
return { item: structuredClone(item), receipt: structuredClone(receipt), duplicate: true };
|
|
88
|
+
}
|
|
89
|
+
if (current < item.updatedAt || current < claimedAt || (hold.hostStartedAt && current < hold.hostStartedAt)) {
|
|
90
|
+
throw new Error("legacy review timestamp precedes the held generation");
|
|
91
|
+
}
|
|
92
|
+
// This attestation is not native quiescence, goal completion, or authority to resume work.
|
|
93
|
+
preserve(runtime, "queue", item, "legacy-host-owner-reviewed", current);
|
|
94
|
+
hold.status = "settled"; hold.settledAt = current;
|
|
95
|
+
item.updatedAt = current; runtime.revision += 1;
|
|
96
|
+
const receipt = appendReceipt(runtime, "legacy-host-owner-reviewed", queueId, current, details);
|
|
97
|
+
await writeJson(paths.gatewayRuntimePath, runtime);
|
|
98
|
+
return { item: structuredClone(item), receipt: structuredClone(receipt), duplicate: false };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
36
102
|
export async function assignGoal({ root = process.cwd(), goalId, agentId, ownerSubjectId, projectId, groupId = null,
|
|
37
103
|
priority = 70, successCriterion, nextSafeStep = null, steps = null, deadline = null, confirmation, now = new Date() }) {
|
|
38
104
|
if (confirmation !== CONFIRMATION) throw new Error("goal assignment requires explicit local owner confirmation");
|
|
@@ -76,6 +142,9 @@ export async function assignGoal({ root = process.cwd(), goalId, agentId, ownerS
|
|
|
76
142
|
if (previous?.plan && previous.plan.definitionsDigest !== goal.plan?.definitionsDigest) {
|
|
77
143
|
throw new Error("goal plan definitions are immutable; assign a new goal ID");
|
|
78
144
|
}
|
|
145
|
+
if (previous && runtime.queue.some((item) => item.goalId === goalId && heldExecution(item))) {
|
|
146
|
+
return { goal: structuredClone(previous), gatewayPolicyPath: paths.gatewayPolicyPath, duplicate: true, held: true };
|
|
147
|
+
}
|
|
79
148
|
if (previous?.plan && previous.status === "blocked") {
|
|
80
149
|
const blockedStep = currentPlanStep(previous);
|
|
81
150
|
if (!blockedStep || blockedStep.status !== "blocked") throw new Error("blocked goal plan has no bound blocked step");
|
|
@@ -299,4 +368,3 @@ export async function reconcileGateway({ root = process.cwd(), now = new Date()
|
|
|
299
368
|
return { policy, runtime, recovered: true };
|
|
300
369
|
});
|
|
301
370
|
}
|
|
302
|
-
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ID_RE, safeCheckpoint, sha256 } from "./gateway-common.js";
|
|
2
|
+
|
|
3
|
+
const SCHEMA = "agentspine.host-execution-hold/v1";
|
|
4
|
+
const validTime = (value) => typeof value === "string" && Number.isFinite(new Date(value).getTime());
|
|
5
|
+
|
|
6
|
+
export function heldExecution(item) {
|
|
7
|
+
return item?.executionHold?.status === "held" ? item.executionHold : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function newExecutionHold(item, resources = null) {
|
|
11
|
+
return { schema: SCHEMA, status: "held", workerId: item.lease.workerId,
|
|
12
|
+
claimedAt: item.lease.claimedAt, attempt: item.attempts,
|
|
13
|
+
hostSessionId: null, hostStartedAt: item.lease.hostStartedAt ?? null,
|
|
14
|
+
resources, settledAt: null, checkpointDigest: null, authority: "execution-state-only" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validExecutionHold(item) {
|
|
18
|
+
const hold = item.executionHold;
|
|
19
|
+
if (hold === undefined) return true;
|
|
20
|
+
return hold && hold.schema === SCHEMA && ["held", "settled"].includes(hold.status)
|
|
21
|
+
&& ID_RE.test(hold.workerId || "") && validTime(hold.claimedAt)
|
|
22
|
+
&& Number.isInteger(hold.attempt) && hold.attempt > 0 && hold.attempt === item.attempts
|
|
23
|
+
&& (hold.hostSessionId === null || ID_RE.test(hold.hostSessionId || ""))
|
|
24
|
+
&& (hold.hostStartedAt === null || validTime(hold.hostStartedAt))
|
|
25
|
+
&& (hold.resources === null || (Array.isArray(hold.resources) && hold.resources.length <= 16
|
|
26
|
+
&& new Set(hold.resources).size === hold.resources.length && hold.resources.every((resource) => ID_RE.test(resource || ""))))
|
|
27
|
+
&& (hold.status === "held" ? hold.settledAt === null && hold.checkpointDigest === null
|
|
28
|
+
: validTime(hold.settledAt) && (hold.checkpointDigest === null || /^[a-f0-9]{64}$/.test(hold.checkpointDigest)))
|
|
29
|
+
&& (!item.lease || (hold.status === "held" && hold.workerId === item.lease.workerId
|
|
30
|
+
&& hold.claimedAt === item.lease.claimedAt))
|
|
31
|
+
&& hold.authority === "execution-state-only";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Old ambiguous records have no resource snapshot. Conservatively retain the whole scope.
|
|
35
|
+
export function retainLegacyExecutionHolds(runtime) {
|
|
36
|
+
for (const item of runtime.queue) {
|
|
37
|
+
if (item.executionHold !== undefined) continue;
|
|
38
|
+
let original = item.lease ? item : null;
|
|
39
|
+
if (!original && ["blocked", "cancelled"].includes(item.status)) {
|
|
40
|
+
original = runtime.history.findLast((entry) => entry.kind === "queue"
|
|
41
|
+
&& entry.objectId === item.queueId && entry.value.attempts === item.attempts
|
|
42
|
+
&& entry.value.lease && entry.value.lease.executionMode !== "read-only")?.value;
|
|
43
|
+
}
|
|
44
|
+
if (original?.lease && original.lease.executionMode !== "read-only") {
|
|
45
|
+
item.executionHold = newExecutionHold(original);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function executionBlocks(heldItem, candidate, resources) {
|
|
51
|
+
const hold = heldExecution(heldItem);
|
|
52
|
+
if (!hold || heldItem.queueId === candidate.queueId) return false;
|
|
53
|
+
if (heldItem.agentId === candidate.agentId) return true;
|
|
54
|
+
return heldItem.projectId === candidate.projectId && heldItem.groupId === candidate.groupId
|
|
55
|
+
&& (hold.resources === null || resources.some((resource) => hold.resources.includes(resource)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function exactHeldGeneration(item, { workerId, claimedAt, attempt }) {
|
|
59
|
+
const hold = heldExecution(item);
|
|
60
|
+
return Boolean(hold && hold.workerId === workerId && hold.claimedAt === claimedAt && hold.attempt === attempt);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function requireHostQuiescence(item, receipt, result, current) {
|
|
64
|
+
const hold = heldExecution(item);
|
|
65
|
+
if (!hold?.hostSessionId) return null;
|
|
66
|
+
const checkedAt = receipt?.checkedAt;
|
|
67
|
+
const age = new Date(current).getTime() - checkedAt;
|
|
68
|
+
if (!receipt || receipt.sessionId !== hold.hostSessionId || receipt.admissionClosed !== true
|
|
69
|
+
|| receipt.status !== "quiescent" || !Array.isArray(receipt.remaining) || receipt.remaining.length !== 0
|
|
70
|
+
|| !Number.isFinite(checkedAt) || age < 0 || age > 30000
|
|
71
|
+
|| checkedAt < new Date(hold.hostStartedAt).getTime()) {
|
|
72
|
+
throw new Error("host settlement requires fresh exact-session quiescence with no remaining work");
|
|
73
|
+
}
|
|
74
|
+
if (item.goalId && (result?.checkpoint === undefined || result.checkpoint === null)) {
|
|
75
|
+
throw new Error("host settlement requires a non-null goal checkpoint");
|
|
76
|
+
}
|
|
77
|
+
return { sessionId: hold.hostSessionId, checkedAt,
|
|
78
|
+
checkpointDigest: item.goalId ? sha256(JSON.stringify(safeCheckpoint(result.checkpoint))) : null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function settleHostExecution(runtime, item, proof, current, appendReceipt) {
|
|
82
|
+
const hold = heldExecution(item);
|
|
83
|
+
if (!hold) return null;
|
|
84
|
+
hold.status = "settled";
|
|
85
|
+
hold.settledAt = current;
|
|
86
|
+
hold.checkpointDigest = proof?.checkpointDigest ?? null;
|
|
87
|
+
return appendReceipt(runtime, "host-execution-settled", item.queueId, current, {
|
|
88
|
+
workerId: hold.workerId, claimedAt: hold.claimedAt, attempt: hold.attempt,
|
|
89
|
+
sessionId: hold.hostSessionId, checkedAt: proof?.checkedAt ?? null,
|
|
90
|
+
checkpointDigest: hold.checkpointDigest, legacy: hold.hostSessionId === null
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { exactHeldGeneration, heldExecution } from "./gateway-host-fencing.js";
|
|
2
|
+
|
|
1
3
|
export const AMBIGUOUS_HOST_OUTCOME =
|
|
2
4
|
"Host execution may have produced an effect before restart; manual owner review is required.";
|
|
3
5
|
|
|
@@ -45,7 +47,13 @@ export function exactGatewayLeaseGeneration(claimedAt, attempt, action) {
|
|
|
45
47
|
return new Date(claimedAt).toISOString();
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export function requireExactGatewayLease(runtime, item, { workerId, claimedAt, attempt, current, action }) {
|
|
50
|
+
export function requireExactGatewayLease(runtime, item, { workerId, claimedAt, attempt, current, action, allowHeld = false }) {
|
|
51
|
+
if (allowHeld && heldExecution(item)?.hostSessionId
|
|
52
|
+
&& exactHeldGeneration(item, { workerId, claimedAt, attempt })) {
|
|
53
|
+
const lane = runtime.lanes.find((entry) => entry.queueId === item.queueId && entry.workerId === workerId
|
|
54
|
+
&& entry.claimedAt === claimedAt && ["leased", "expired"].includes(entry.status));
|
|
55
|
+
if (lane) return lane;
|
|
56
|
+
}
|
|
49
57
|
if (!item || item.status !== "leased" || item.attempts !== attempt
|
|
50
58
|
|| item.lease?.workerId !== workerId || item.lease.claimedAt !== claimedAt
|
|
51
59
|
|| new Date(item.lease.expiresAt) <= new Date(current)) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { markGatewayHostStarted } from "./gateway-runs.js";
|
|
2
|
+
|
|
3
|
+
export async function executePreparedHost({ root, item, hostFactory, env, clock }) {
|
|
4
|
+
const host = await hostFactory(item, { env });
|
|
5
|
+
if (!host || typeof host.sessionId !== "string" || typeof host.run !== "function"
|
|
6
|
+
|| typeof host.quiesce !== "function") throw new Error("prepared host requires an exact session, run and quiesce");
|
|
7
|
+
try {
|
|
8
|
+
await markGatewayHostStarted({ root, queueId: item.queueId, workerId: item.lease.workerId,
|
|
9
|
+
claimedAt: item.lease.claimedAt, attempt: item.attempts, hostSessionId: host.sessionId, now: clock() });
|
|
10
|
+
} catch (error) {
|
|
11
|
+
await host.quiesce().catch(() => {});
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
let result;
|
|
15
|
+
try { result = await host.run(); }
|
|
16
|
+
catch {
|
|
17
|
+
result = { text: "Host step failed; the last known checkpoint is retained.", blocked: true,
|
|
18
|
+
blocker: "Host step failed; inspect the saved session before continuing.",
|
|
19
|
+
checkpoint: item.goal?.checkpoint ?? { hostStepFailed: true, sessionId: host.sessionId } };
|
|
20
|
+
}
|
|
21
|
+
// This is a host-returned observation, separate from model-controlled result JSON.
|
|
22
|
+
const quiescence = await host.quiesce();
|
|
23
|
+
if (quiescence?.sessionId !== host.sessionId || quiescence.status !== "quiescent"
|
|
24
|
+
|| quiescence.admissionClosed !== true || !Array.isArray(quiescence.remaining) || quiescence.remaining.length) {
|
|
25
|
+
throw new Error("Host execution termination remains unconfirmed");
|
|
26
|
+
}
|
|
27
|
+
return { result, quiescence };
|
|
28
|
+
}
|