blun-king-cli 9.1.515 → 9.1.516
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 +11 -0
- package/LIESMICH.txt +2 -2
- package/README.md +2 -2
- package/bin/launcher-runtime.js +7 -30
- package/bin/plugin-bootstrap.js +13 -2
- package/bin/todo-list-turn-policy.cjs +21 -0
- package/blun.mjs +66 -30
- package/package.json +8 -2
- package/scripts/check-active-profile-plugin-startup.js +36 -0
- package/scripts/check-mcp-startup-wait-budget.js +48 -0
- package/scripts/check-plugin-startup-regression.js +53 -0
- package/scripts/check-queue-controls-regression.js +189 -0
- package/scripts/check-resume-replay-regression.js +100 -0
- package/scripts/check-telegram-bridge-watchdog.js +60 -0
- package/scripts/check-todo-loop-regression.js +52 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.1.516 - 2026-08-31
|
|
4
|
+
|
|
5
|
+
- Caps the first tool-capable turn's wait for the initial MCP catalogue at 2.5 seconds, so a cold optional desktop server can finish in the background instead of blocking Read, Bash, Grep, TodoList, Telegram, and AgentSpine.
|
|
6
|
+
- Verifies and installs AgentSpine only for the active profile at startup; dormant profiles are verified when they are launched, preventing startup time from growing with abandoned proof profiles.
|
|
7
|
+
- Reuses the immutable packaged plugin source snapshot across managed-plugin checks while still reading and byte-verifying every active installed plugin tree, and logs each MCP server's startup status for reproducible timing evidence.
|
|
8
|
+
- Retires an active-steer queue entry exactly once as soon as the core has durably accepted its `turn.steer`, instead of restoring an already delivered message after a 15-second UI confirmation timeout; genuine rejections remain queued.
|
|
9
|
+
- Restarts an owned Telegram bridge after an unexpected exit or launch error with one deduplicated timer, while ownership loss and normal shutdown still prevent respawn.
|
|
10
|
+
- Reconstructs a missing assistant step when a persisted `content.part` or tool call follows full compaction without its `step.begin`, preserving the continuation instead of making `king -c` fail during replay.
|
|
11
|
+
- Allows only the first TodoList write in a model step, preventing parallel list replacements from racing while leaving read-only list queries untouched.
|
|
12
|
+
- Extends the package gates for Ctrl+S, Escape, durable queue acknowledgement, rejection replay, stable urgent/direct/addressed/bot/normal priority, Todo lifecycle, visible pending markers, valid initial Todo state, and single-bridge recovery.
|
|
13
|
+
|
|
3
14
|
## 9.1.515 - 2026-08-31
|
|
4
15
|
|
|
5
16
|
- Accepts a truthful unchanged TodoList during mandatory maintenance instead of terminating the turn after five identical retries.
|
package/LIESMICH.txt
CHANGED
|
@@ -9,11 +9,11 @@ Installation
|
|
|
9
9
|
------------
|
|
10
10
|
Die geprüfte Version exakt global installieren:
|
|
11
11
|
|
|
12
|
-
npm install -g blun-king-cli@9.1.
|
|
12
|
+
npm install -g blun-king-cli@9.1.516
|
|
13
13
|
|
|
14
14
|
AgentSpine 0.10.1
|
|
15
15
|
-----------------
|
|
16
|
-
Version 9.1.
|
|
16
|
+
Version 9.1.516 enthält weiterhin AgentSpine 0.10.1 als inhaltsadressierte Pluginfassung. Der Preflight prüft jede aktive Host-Anweisungsdatei weiterhin race-sicher und bindet SHA-256 sowie Dateiidentität an den Zug, dupliziert den bereits vom Host geladenen Volltext aber nicht im Laufzeitkontext. Die reale Probe mit einer 15.519 Byte großen `CLAUDE.md` blieb dadurch bei 5.667 injizierten Byte. Der Stand enthält außerdem den selbstheilenden Persona- und Beziehungsgraphen, die begrenzte Telegram-Mnemo-Abfrage und eine sichtbare Fünf-Sekunden-Grenze für lokale Beziehungsabfragen.
|
|
17
17
|
|
|
18
18
|
Start
|
|
19
19
|
-----
|
package/README.md
CHANGED
|
@@ -9,12 +9,12 @@ Voraussetzung ist Node.js 24.15 oder neuer. Die geprüfte Version wird exakt
|
|
|
9
9
|
installiert:
|
|
10
10
|
|
|
11
11
|
```powershell
|
|
12
|
-
npm install -g blun-king-cli@9.1.
|
|
12
|
+
npm install -g blun-king-cli@9.1.516
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## AgentSpine 0.10.1
|
|
16
16
|
|
|
17
|
-
Version 9.1.
|
|
17
|
+
Version 9.1.516 enthält weiterhin AgentSpine 0.10.1 als inhaltsadressierte Pluginfassung. Der Preflight prüft jede aktive Host-Anweisungsdatei weiterhin race-sicher und bindet SHA-256 sowie Dateiidentität an den Zug, dupliziert den bereits vom Host geladenen Volltext aber nicht im Laufzeitkontext. Die reale Probe mit einer 15.519 Byte großen `CLAUDE.md` blieb dadurch bei 5.667 injizierten Byte. Der Stand enthält außerdem den selbstheilenden Persona- und Beziehungsgraphen, die begrenzte Telegram-Mnemo-Abfrage und eine sichtbare Fünf-Sekunden-Grenze für lokale Beziehungsabfragen.
|
|
18
18
|
|
|
19
19
|
## Reproduzierbares Staging und Packen
|
|
20
20
|
|
package/bin/launcher-runtime.js
CHANGED
|
@@ -282,36 +282,13 @@ function installAgentSpineForProfile(packageRoot, blunDir) {
|
|
|
282
282
|
installManagedAgentSpinePlugin({ packageRoot, blunDir });
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
function installAgentSpineForExistingProfiles(packageRoot,
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
addProfileHome(currentProfileHome);
|
|
295
|
-
if (fs.existsSync(profileRoot)) {
|
|
296
|
-
for (const entry of fs.readdirSync(profileRoot, { withFileTypes: true })) {
|
|
297
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
298
|
-
let profileHome;
|
|
299
|
-
try {
|
|
300
|
-
profileHome = resolveProfilePaths(sharedHome, entry.name).home;
|
|
301
|
-
} catch {
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const stat = fs.lstatSync(profileHome);
|
|
305
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
|
|
306
|
-
addProfileHome(profileHome);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
for (const profileHome of profileHomes.values()) {
|
|
311
|
-
ensurePrivateDirectory(profileHome);
|
|
312
|
-
installAgentSpineForProfile(packageRoot, profileHome);
|
|
313
|
-
}
|
|
314
|
-
return [...profileHomes.values()];
|
|
285
|
+
function installAgentSpineForExistingProfiles(packageRoot, _sharedHome, currentProfileHome) {
|
|
286
|
+
// A dormant profile is verified when that profile is launched. Rescanning all
|
|
287
|
+
// profiles here makes every startup slower as old profiles accumulate.
|
|
288
|
+
const profileHome = path.resolve(currentProfileHome);
|
|
289
|
+
ensurePrivateDirectory(profileHome);
|
|
290
|
+
installAgentSpineForProfile(packageRoot, profileHome);
|
|
291
|
+
return [profileHome];
|
|
315
292
|
}
|
|
316
293
|
|
|
317
294
|
function createManagedNodeEnvironment(binary, env = process.env, platform = process.platform) {
|
package/bin/plugin-bootstrap.js
CHANGED
|
@@ -11,6 +11,8 @@ const {
|
|
|
11
11
|
writePrivateFile,
|
|
12
12
|
} = require('./private-paths');
|
|
13
13
|
|
|
14
|
+
const managedPluginSourceSnapshots = new Map();
|
|
15
|
+
|
|
14
16
|
function isObject(value) {
|
|
15
17
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
18
|
}
|
|
@@ -42,9 +44,18 @@ function snapshotPluginTree(root) {
|
|
|
42
44
|
return visit(root) ? entries : null;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
function snapshotManagedPluginSource(root) {
|
|
48
|
+
const resolvedRoot = path.resolve(root);
|
|
49
|
+
const cacheKey = process.platform === 'win32' ? resolvedRoot.toLowerCase() : resolvedRoot;
|
|
50
|
+
if (!managedPluginSourceSnapshots.has(cacheKey)) {
|
|
51
|
+
managedPluginSourceSnapshots.set(cacheKey, snapshotPluginTree(resolvedRoot));
|
|
52
|
+
}
|
|
53
|
+
return managedPluginSourceSnapshots.get(cacheKey);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
function pluginTreesAreIdentical(sourceRoot, pluginRoot) {
|
|
46
57
|
try {
|
|
47
|
-
const source =
|
|
58
|
+
const source = snapshotManagedPluginSource(sourceRoot);
|
|
48
59
|
const installed = snapshotPluginTree(pluginRoot);
|
|
49
60
|
if (source === null || installed === null || source.length !== installed.length) return false;
|
|
50
61
|
return source.every((entry, index) => {
|
|
@@ -59,7 +70,7 @@ function pluginTreesAreIdentical(sourceRoot, pluginRoot) {
|
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
function pluginTreeDigest(root) {
|
|
62
|
-
const entries =
|
|
73
|
+
const entries = snapshotManagedPluginSource(root);
|
|
63
74
|
if (entries === null) throw new Error(`Invalid managed plugin tree: ${root}`);
|
|
64
75
|
const hash = createHash('sha256');
|
|
65
76
|
for (const entry of entries) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const TODO_LIST_TOOL_NAME = 'TodoList';
|
|
4
|
+
|
|
5
|
+
function enforceSingleTodoWritePerStep(context) {
|
|
6
|
+
if (context?.toolCall?.name !== TODO_LIST_TOOL_NAME) return undefined;
|
|
7
|
+
if (!Array.isArray(context.args?.todos)) return undefined;
|
|
8
|
+
|
|
9
|
+
const todoWrites = Array.isArray(context.toolCalls)
|
|
10
|
+
? context.toolCalls.filter((call) => call?.name === TODO_LIST_TOOL_NAME)
|
|
11
|
+
: [];
|
|
12
|
+
if (todoWrites.length <= 1) return undefined;
|
|
13
|
+
if (context.toolCall.id === todoWrites[0]?.id) return undefined;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
block: true,
|
|
17
|
+
reason: 'Only one TodoList write is allowed per model step. Use the first update, then continue from its stored result in the next step.',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { enforceSingleTodoWritePerStep };
|
package/blun.mjs
CHANGED
|
@@ -79167,6 +79167,25 @@ var init_context$2 = __esmMin((() => {
|
|
|
79167
79167
|
closeAbandonedToolExchange(output) {
|
|
79168
79168
|
return this.closePendingToolResults(output).length;
|
|
79169
79169
|
}
|
|
79170
|
+
recoverMissingOpenStep(event) {
|
|
79171
|
+
const resolvedStepUuid = resolveStepEventUuid(this.openSteps, event);
|
|
79172
|
+
const existingStep = this.openSteps.get(resolvedStepUuid);
|
|
79173
|
+
if (existingStep !== void 0) return existingStep;
|
|
79174
|
+
const recoveredStepUuid = event.stepUuid ?? `recovered-${this._history.length}-${event.type}`;
|
|
79175
|
+
const message = {
|
|
79176
|
+
role: "assistant",
|
|
79177
|
+
content: [],
|
|
79178
|
+
toolCalls: []
|
|
79179
|
+
};
|
|
79180
|
+
this.pushHistory(message);
|
|
79181
|
+
this.openSteps.set(recoveredStepUuid, message);
|
|
79182
|
+
this.agent.log.warn("recovered loop event without step.begin", {
|
|
79183
|
+
eventType: event.type,
|
|
79184
|
+
stepUuid: event.stepUuid,
|
|
79185
|
+
restoring: this.agent.records.restoring !== null
|
|
79186
|
+
});
|
|
79187
|
+
return message;
|
|
79188
|
+
}
|
|
79170
79189
|
appendLoopEvent(event) {
|
|
79171
79190
|
this.agent.records.logRecord({
|
|
79172
79191
|
type: "context.append_loop_event",
|
|
@@ -79208,17 +79227,13 @@ var init_context$2 = __esmMin((() => {
|
|
|
79208
79227
|
return;
|
|
79209
79228
|
}
|
|
79210
79229
|
case "content.part": {
|
|
79211
|
-
const
|
|
79212
|
-
const openStep = this.openSteps.get(stepEventUuid);
|
|
79213
|
-
if (openStep === void 0) throw new Error(`Received content_part for unknown step_uuid '${stepEventUuid}' (no open step_begin)`);
|
|
79230
|
+
const openStep = this.recoverMissingOpenStep(event);
|
|
79214
79231
|
openStep.content.push(event.part);
|
|
79215
79232
|
this.markPendingTokenEstimateDirty();
|
|
79216
79233
|
return;
|
|
79217
79234
|
}
|
|
79218
79235
|
case "tool.call": {
|
|
79219
|
-
const
|
|
79220
|
-
const openStep = this.openSteps.get(stepEventUuid);
|
|
79221
|
-
if (openStep === void 0) throw new Error(`Received tool_call for unknown step_uuid '${stepEventUuid}' (no open step_begin)`);
|
|
79236
|
+
const openStep = this.recoverMissingOpenStep(event);
|
|
79222
79237
|
openStep.toolCalls.push({
|
|
79223
79238
|
type: "function",
|
|
79224
79239
|
id: event.toolCallId,
|
|
@@ -261610,6 +261625,13 @@ function blunTurnNeedsInitialMcp(input, origin) {
|
|
|
261610
261625
|
if (blunTurnNeedsTools(input, origin)) return true;
|
|
261611
261626
|
return BLUN_TELEGRAM_CHANNEL_RE.test(blunExtractText(input)) || blunTurnHasAttachment(input);
|
|
261612
261627
|
}
|
|
261628
|
+
async function blunWaitForInitialMcpBudget(mcp, signal) {
|
|
261629
|
+
if (mcp === void 0) return;
|
|
261630
|
+
await Promise.race([
|
|
261631
|
+
mcp.waitForInitialLoad(signal),
|
|
261632
|
+
new Promise((resolve) => setTimeout(resolve, 2500))
|
|
261633
|
+
]);
|
|
261634
|
+
}
|
|
261613
261635
|
function blunMessageTextChars(message) {
|
|
261614
261636
|
return message.content.reduce((total, part) => total + (part.type === "text" ? part.text.length : 0), 0);
|
|
261615
261637
|
}
|
|
@@ -261919,7 +261941,7 @@ function durablePromptOrigin(origin) {
|
|
|
261919
261941
|
void externalReportSources;
|
|
261920
261942
|
return durableOrigin;
|
|
261921
261943
|
}
|
|
261922
|
-
var BLUN_CORE_TOOL_NAMES, BLUN_LEAN_TOOL_NAMES, BLUN_ATTACHMENT_MARKER_RE, BLUN_TELEGRAM_OUTBOUND_TOOL_RE, BLUN_TELEGRAM_CHANNEL_RE, BLUN_TOOL_BUDGET_RATIO, createDeferredToolLoader, mediaToolNamesForTurnText, rankedSupportToolNamesForGoal, rankedToolNamesForTurnText, rememberDeferredToolAfterNotFound, toolSchemaBudgetTokens, projectRecurringCronHistory, TelegramDeliveryLedger, createRuntimeCognitiveTurnLifecycle, cognitiveFocusScopesForTurn, buildCognitiveWorkFocus, buildAttentionQueueItem, LLM_NOT_SET_MESSAGE, GOAL_CONTINUATION_ORIGIN, GOAL_COMPLETION_REMINDER_NAME, GOAL_BLOCKED_REMINDER_NAME, GOAL_RATE_LIMIT_PAUSE_REASON, GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, GOAL_PROVIDER_AUTH_PAUSE_PREFIX, GOAL_PROVIDER_API_PAUSE_PREFIX, GOAL_MODEL_CONFIG_PAUSE_PREFIX, GOAL_RUNTIME_PAUSE_PREFIX, GOAL_PROVIDER_FILTERED_PAUSE_REASON, GOAL_CONTINUATION_PROMPT, TurnFlow;
|
|
261944
|
+
var BLUN_CORE_TOOL_NAMES, BLUN_LEAN_TOOL_NAMES, BLUN_ATTACHMENT_MARKER_RE, BLUN_TELEGRAM_OUTBOUND_TOOL_RE, BLUN_TELEGRAM_CHANNEL_RE, BLUN_TOOL_BUDGET_RATIO, createDeferredToolLoader, mediaToolNamesForTurnText, rankedSupportToolNamesForGoal, rankedToolNamesForTurnText, rememberDeferredToolAfterNotFound, toolSchemaBudgetTokens, enforceSingleTodoWritePerStep, projectRecurringCronHistory, TelegramDeliveryLedger, createRuntimeCognitiveTurnLifecycle, cognitiveFocusScopesForTurn, buildCognitiveWorkFocus, buildAttentionQueueItem, LLM_NOT_SET_MESSAGE, GOAL_CONTINUATION_ORIGIN, GOAL_COMPLETION_REMINDER_NAME, GOAL_BLOCKED_REMINDER_NAME, GOAL_RATE_LIMIT_PAUSE_REASON, GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, GOAL_PROVIDER_AUTH_PAUSE_PREFIX, GOAL_PROVIDER_API_PAUSE_PREFIX, GOAL_MODEL_CONFIG_PAUSE_PREFIX, GOAL_RUNTIME_PAUSE_PREFIX, GOAL_PROVIDER_FILTERED_PAUSE_REASON, GOAL_CONTINUATION_PROMPT, TurnFlow;
|
|
261923
261945
|
var init_turn = __esmMin((() => {
|
|
261924
261946
|
init_dist$4();
|
|
261925
261947
|
init_src$4();
|
|
@@ -261937,6 +261959,7 @@ var init_turn = __esmMin((() => {
|
|
|
261937
261959
|
init_user_message_offload();
|
|
261938
261960
|
init_assistant_message_offload();
|
|
261939
261961
|
({ CORE_TOOL_NAMES: BLUN_CORE_TOOL_NAMES, createDeferredToolLoader, mediaToolNamesForTurnText, rankedSupportToolNamesForGoal, rankedToolNamesForTurnText, rememberDeferredToolAfterNotFound, toolSchemaBudgetTokens } = createRequire(import.meta.url)("./bin/turn-tool-performance-policy.cjs"));
|
|
261962
|
+
({ enforceSingleTodoWritePerStep } = createRequire(import.meta.url)("./bin/todo-list-turn-policy.cjs"));
|
|
261940
261963
|
({ projectRecurringCronHistory } = createRequire(import.meta.url)("./bin/recurring-cron-history-policy.cjs"));
|
|
261941
261964
|
({ TelegramDeliveryLedger } = createRequire(import.meta.url)("./bin/repeated-user-message-projection.cjs"));
|
|
261942
261965
|
({ createRuntimeCognitiveTurnLifecycle } = createRequire(import.meta.url)("./bin/cognitive-turn-lifecycle.cjs"));
|
|
@@ -262750,7 +262773,7 @@ var init_turn = __esmMin((() => {
|
|
|
262750
262773
|
let goalOutcomeMessageContinuationUsed = false;
|
|
262751
262774
|
const directReplyTurnStop = createDirectReplyTurnStop(blunExtractText(input));
|
|
262752
262775
|
const deduper = new ToolCallDeduplicator({ telemetry: this.agent.telemetry });
|
|
262753
|
-
if (blunTurnNeedsInitialMcp(input, origin)) await this.agent.mcp
|
|
262776
|
+
if (blunTurnNeedsInitialMcp(input, origin)) await blunWaitForInitialMcpBudget(this.agent.mcp, signal);
|
|
262754
262777
|
const personalMemoryRecall = await this.agent.injection.injectPersonalMemoryForTurn(turnId, input, origin, signal);
|
|
262755
262778
|
await this.agent.injection.injectGoal();
|
|
262756
262779
|
const cognitiveProjection = this.projectCognitiveState(turnId, input);
|
|
@@ -262953,6 +262976,8 @@ var init_turn = __esmMin((() => {
|
|
|
262953
262976
|
return { continue: false };
|
|
262954
262977
|
},
|
|
262955
262978
|
prepareToolExecution: async (ctx) => {
|
|
262979
|
+
const todoTurnPolicy = enforceSingleTodoWritePerStep(ctx);
|
|
262980
|
+
if (todoTurnPolicy !== void 0) return todoTurnPolicy;
|
|
262956
262981
|
const goalTodoPolicy = enforceGoalTodoPolicy(this.agent, ctx);
|
|
262957
262982
|
if (goalTodoPolicy !== void 0) return goalTodoPolicy;
|
|
262958
262983
|
const ideaPolicy = enforceIdeaToolPolicy(this.agent, ctx);
|
|
@@ -298308,6 +298333,11 @@ var init_session$1 = __esmMin((() => {
|
|
|
298308
298333
|
});
|
|
298309
298334
|
}
|
|
298310
298335
|
onMcpServerStatusChange(entry) {
|
|
298336
|
+
this.log.info("mcp server startup status", {
|
|
298337
|
+
server: entry.name,
|
|
298338
|
+
status: entry.status,
|
|
298339
|
+
toolCount: entry.toolCount
|
|
298340
|
+
});
|
|
298311
298341
|
this.rpc.emitEvent({
|
|
298312
298342
|
type: "mcp.server.status",
|
|
298313
298343
|
agentId: "main",
|
|
@@ -419872,7 +419902,7 @@ const OWNERSHIP_SETTLE_MS = 50;
|
|
|
419872
419902
|
const OWNERSHIP_HANDOFF_WARNING_MS = 5e3;
|
|
419873
419903
|
const BRIDGE_STOP_TIMEOUT_MS = 2500;
|
|
419874
419904
|
const BRIDGE_FORCE_STOP_TIMEOUT_MS = 500;
|
|
419875
|
-
const
|
|
419905
|
+
const BRIDGE_RESTART_DELAY_MS = 1e3;
|
|
419876
419906
|
const CHANNEL_DELIVERY_MAX_FAILURES = 3;
|
|
419877
419907
|
const BLUN_NODE_FALLBACK_SUBCOMMAND = "__plugin_run_node";
|
|
419878
419908
|
function isBlunNativeBinary(execPath) {
|
|
@@ -419990,6 +420020,7 @@ var TelegramChannelController = class {
|
|
|
419990
420020
|
ownershipTimer;
|
|
419991
420021
|
activationTimer;
|
|
419992
420022
|
handoffTimer;
|
|
420023
|
+
bridgeRestartTimer;
|
|
419993
420024
|
offset = 0;
|
|
419994
420025
|
checkpointOffset = 0;
|
|
419995
420026
|
queueFileId = "";
|
|
@@ -420006,6 +420037,7 @@ var TelegramChannelController = class {
|
|
|
420006
420037
|
spawnBridge;
|
|
420007
420038
|
bridgeStopTimeoutMs;
|
|
420008
420039
|
bridgeForceStopTimeoutMs;
|
|
420040
|
+
bridgeRestartDelayMs;
|
|
420009
420041
|
ownerId;
|
|
420010
420042
|
ownershipPollMs;
|
|
420011
420043
|
ownershipSettleMs;
|
|
@@ -420017,6 +420049,7 @@ var TelegramChannelController = class {
|
|
|
420017
420049
|
this.spawnBridge = options.spawnBridge ?? spawn;
|
|
420018
420050
|
this.bridgeStopTimeoutMs = options.bridgeStopTimeoutMs ?? BRIDGE_STOP_TIMEOUT_MS;
|
|
420019
420051
|
this.bridgeForceStopTimeoutMs = options.bridgeForceStopTimeoutMs ?? BRIDGE_FORCE_STOP_TIMEOUT_MS;
|
|
420052
|
+
this.bridgeRestartDelayMs = options.bridgeRestartDelayMs ?? BRIDGE_RESTART_DELAY_MS;
|
|
420020
420053
|
this.ownerId = options.ownerId ?? randomUUID();
|
|
420021
420054
|
this.ownershipPollMs = options.ownershipPollMs ?? OWNERSHIP_POLL_MS;
|
|
420022
420055
|
this.ownershipSettleMs = options.ownershipSettleMs ?? OWNERSHIP_SETTLE_MS;
|
|
@@ -420192,6 +420225,7 @@ var TelegramChannelController = class {
|
|
|
420192
420225
|
if (this.ownershipTimer !== void 0) clearInterval(this.ownershipTimer);
|
|
420193
420226
|
if (this.activationTimer !== void 0) clearTimeout(this.activationTimer);
|
|
420194
420227
|
if (this.handoffTimer !== void 0) clearTimeout(this.handoffTimer);
|
|
420228
|
+
if (this.bridgeRestartTimer !== void 0) clearTimeout(this.bridgeRestartTimer);
|
|
420195
420229
|
try {
|
|
420196
420230
|
if (parseInt(readFileSync(this.leaseFile, "utf8"), 10) === process.pid) rmSync(this.leaseFile);
|
|
420197
420231
|
} catch {}
|
|
@@ -420362,6 +420396,15 @@ var TelegramChannelController = class {
|
|
|
420362
420396
|
* (it will see our fresh lease and route to us). Otherwise start one bridge
|
|
420363
420397
|
* owned by this controller; shutdown never signals a pre-existing process.
|
|
420364
420398
|
*/
|
|
420399
|
+
scheduleBridgeRestart() {
|
|
420400
|
+
if (this.stopped || !this.activeOwner || !this.ownsChannel() || this.bridgeRestartTimer !== void 0) return;
|
|
420401
|
+
this.bridgeRestartTimer = setTimeout(() => {
|
|
420402
|
+
this.bridgeRestartTimer = void 0;
|
|
420403
|
+
if (this.stopped || !this.activeOwner || !this.ownsChannel()) return;
|
|
420404
|
+
this.ensureBridgeRunning();
|
|
420405
|
+
}, this.bridgeRestartDelayMs);
|
|
420406
|
+
this.bridgeRestartTimer.unref?.();
|
|
420407
|
+
}
|
|
420365
420408
|
ensureBridgeRunning() {
|
|
420366
420409
|
if (this.liveBridgePid() !== void 0) return;
|
|
420367
420410
|
const bridge = this.resolveBridgeEntry();
|
|
@@ -420384,14 +420427,17 @@ var TelegramChannelController = class {
|
|
|
420384
420427
|
child.once("exit", () => {
|
|
420385
420428
|
this.removeOwnedBridgePid(child.pid);
|
|
420386
420429
|
if (this.ownedBridge === child) this.ownedBridge = void 0;
|
|
420430
|
+
this.scheduleBridgeRestart();
|
|
420387
420431
|
});
|
|
420388
420432
|
child.once("error", (error) => {
|
|
420389
420433
|
this.removeOwnedBridgePid(child.pid);
|
|
420390
420434
|
if (this.ownedBridge === child) this.ownedBridge = void 0;
|
|
420391
420435
|
if (!this.stopped) this.host.warn(uiText("telegramChannel.bridgeStartFailed", { error: String(error) }));
|
|
420436
|
+
this.scheduleBridgeRestart();
|
|
420392
420437
|
});
|
|
420393
420438
|
} catch (error) {
|
|
420394
420439
|
this.host.warn(uiText("telegramChannel.bridgeStartFailed", { error: String(error) }));
|
|
420440
|
+
this.scheduleBridgeRestart();
|
|
420395
420441
|
}
|
|
420396
420442
|
}
|
|
420397
420443
|
resolveBridgeEntry() {
|
|
@@ -518332,21 +518378,7 @@ var BlunTUI = class {
|
|
|
518332
518378
|
try {
|
|
518333
518379
|
const result = await this.harness.withInteractiveAgent(item.agentId ?? "main", () => session.steerActive(input, expectedTurnId === void 0 ? {} : { expectedTurnId }));
|
|
518334
518380
|
if (!result.accepted) return this.recoverRejectedActiveSteer(inFlight);
|
|
518335
|
-
inFlight
|
|
518336
|
-
inFlight.accepted = true;
|
|
518337
|
-
for (const queued of items) this.traceTelegramDelivery?.({ stage: "accepted", ...queued.channelDeliveryTrace, route: "active_steer", turnId: inFlight.turnId });
|
|
518338
|
-
if (result.duplicate === true) {
|
|
518339
|
-
inFlight.duplicate = true;
|
|
518340
|
-
return this.commitQueuedSteer(inFlight);
|
|
518341
|
-
}
|
|
518342
|
-
if (inFlight.turnEnded) return restoreHead();
|
|
518343
|
-
if (!inFlight.stepStarted) {
|
|
518344
|
-
inFlight.confirmationTimer = setTimeout(() => {
|
|
518345
|
-
if (this.queueSteerInFlight === inFlight && inFlight.accepted && !inFlight.stepStarted) restoreHead(new Error("channel steer confirmation timed out"));
|
|
518346
|
-
}, CHANNEL_STEER_CONFIRM_TIMEOUT_MS);
|
|
518347
|
-
inFlight.confirmationTimer.unref?.();
|
|
518348
|
-
}
|
|
518349
|
-
this.commitQueuedSteerIfReady(inFlight);
|
|
518381
|
+
return this.acceptQueuedSteer(inFlight, result, true);
|
|
518350
518382
|
} catch (error) {
|
|
518351
518383
|
return restoreHead(error);
|
|
518352
518384
|
}
|
|
@@ -518462,6 +518494,15 @@ var BlunTUI = class {
|
|
|
518462
518494
|
this.updateQueueDisplay();
|
|
518463
518495
|
this.state.ui.requestRender();
|
|
518464
518496
|
}
|
|
518497
|
+
acceptQueuedSteer(inFlight, result, traceDelivery = false) {
|
|
518498
|
+
if (this.queueSteerInFlight !== inFlight || !result.accepted) return false;
|
|
518499
|
+
inFlight.turnId = String(result.turnId);
|
|
518500
|
+
inFlight.accepted = true;
|
|
518501
|
+
inFlight.duplicate = result.duplicate === true;
|
|
518502
|
+
if (traceDelivery) for (const queued of inFlight.items) this.traceTelegramDelivery?.({ stage: "accepted", ...queued.channelDeliveryTrace, route: "active_steer", turnId: inFlight.turnId });
|
|
518503
|
+
this.commitQueuedSteer(inFlight);
|
|
518504
|
+
return true;
|
|
518505
|
+
}
|
|
518465
518506
|
commitQueuedSteerIfReady(inFlight) {
|
|
518466
518507
|
if (this.queueSteerInFlight !== inFlight || !inFlight.accepted || !inFlight.stepStarted || inFlight.turnEnded) return;
|
|
518467
518508
|
this.commitQueuedSteer(inFlight);
|
|
@@ -518647,12 +518688,7 @@ var BlunTUI = class {
|
|
|
518647
518688
|
session.steerActive(items.map((item) => item.text.trim()).join("\n\n"), expectedTurnId === void 0 ? {} : { expectedTurnId }).then((result) => {
|
|
518648
518689
|
if (this.queueSteerInFlight !== inFlight) return;
|
|
518649
518690
|
if (!result.accepted) this.recoverRejectedActiveSteer(inFlight);
|
|
518650
|
-
else
|
|
518651
|
-
inFlight.turnId = String(result.turnId);
|
|
518652
|
-
inFlight.accepted = true;
|
|
518653
|
-
if (inFlight.turnEnded) this.restoreQueuedSteer(inFlight);
|
|
518654
|
-
else this.commitQueuedSteerIfReady(inFlight);
|
|
518655
|
-
}
|
|
518691
|
+
else this.acceptQueuedSteer(inFlight, result);
|
|
518656
518692
|
}).catch((error) => {
|
|
518657
518693
|
this.restoreQueuedSteer(inFlight, error);
|
|
518658
518694
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blun-king-cli",
|
|
3
|
-
"version": "9.1.
|
|
3
|
+
"version": "9.1.516",
|
|
4
4
|
"description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node --test test/*.test.js",
|
|
12
|
-
"prepack": "node scripts/check-release-metadata.js && node scripts/check-todo-loop-regression.js",
|
|
12
|
+
"prepack": "node scripts/check-release-metadata.js && node scripts/check-todo-loop-regression.js && node scripts/check-queue-controls-regression.js && node scripts/check-telegram-bridge-watchdog.js && node scripts/check-resume-replay-regression.js && node scripts/check-plugin-startup-regression.js && node scripts/check-active-profile-plugin-startup.js && node scripts/check-mcp-startup-wait-budget.js",
|
|
13
13
|
"release:verify": "node scripts/check-release-metadata.js --external",
|
|
14
14
|
"postinstall": "node scripts/fix-node-pty-perms.js"
|
|
15
15
|
},
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
"native/",
|
|
37
37
|
"release-planned-removals.json",
|
|
38
38
|
"scripts/check-package-regression.js",
|
|
39
|
+
"scripts/check-active-profile-plugin-startup.js",
|
|
40
|
+
"scripts/check-mcp-startup-wait-budget.js",
|
|
41
|
+
"scripts/check-plugin-startup-regression.js",
|
|
42
|
+
"scripts/check-queue-controls-regression.js",
|
|
43
|
+
"scripts/check-telegram-bridge-watchdog.js",
|
|
44
|
+
"scripts/check-resume-replay-regression.js",
|
|
39
45
|
"scripts/check-release-metadata.js",
|
|
40
46
|
"scripts/check-todo-loop-regression.js",
|
|
41
47
|
"scripts/fix-node-pty-perms.js",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const { installAgentSpineForExistingProfiles } = require('../bin/launcher-runtime');
|
|
9
|
+
|
|
10
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
11
|
+
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'blun-active-profile-plugin-'));
|
|
12
|
+
const sharedHome = path.join(temporaryRoot, '.blun');
|
|
13
|
+
const currentProfile = path.join(sharedHome, 'profile', 'current');
|
|
14
|
+
const dormantProfile = path.join(sharedHome, 'profile', 'dormant');
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
fs.mkdirSync(currentProfile, { recursive: true });
|
|
18
|
+
fs.mkdirSync(dormantProfile, { recursive: true });
|
|
19
|
+
|
|
20
|
+
installAgentSpineForExistingProfiles(packageRoot, sharedHome, currentProfile);
|
|
21
|
+
|
|
22
|
+
assert.equal(
|
|
23
|
+
fs.existsSync(path.join(currentProfile, 'plugins', 'installed.json')),
|
|
24
|
+
true,
|
|
25
|
+
'the active profile must receive the managed AgentSpine plugin',
|
|
26
|
+
);
|
|
27
|
+
assert.equal(
|
|
28
|
+
fs.existsSync(path.join(dormantProfile, 'plugins', 'installed.json')),
|
|
29
|
+
false,
|
|
30
|
+
'startup must not rescan or mutate dormant profiles',
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
process.stdout.write('active profile plugin startup regression: PASS\n');
|
|
34
|
+
} finally {
|
|
35
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const vm = require('node:vm');
|
|
7
|
+
|
|
8
|
+
const bundlePath = path.resolve(__dirname, '..', 'blun.mjs');
|
|
9
|
+
const source = fs.readFileSync(bundlePath, 'utf8');
|
|
10
|
+
const match = source.match(
|
|
11
|
+
/async function blunWaitForInitialMcpBudget\(mcp, signal\) \{[\s\S]*?\n\}/u,
|
|
12
|
+
);
|
|
13
|
+
assert.ok(match, 'the bundle must contain the bounded initial MCP wait helper');
|
|
14
|
+
assert.match(
|
|
15
|
+
source,
|
|
16
|
+
/await blunWaitForInitialMcpBudget\(this\.agent\.mcp, signal\)/u,
|
|
17
|
+
'tool-capable turns must use the bounded initial MCP wait helper',
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const context = vm.createContext({ setTimeout });
|
|
21
|
+
vm.runInContext(`${match[0]}; globalThis.waitForInitialMcp = blunWaitForInitialMcpBudget;`, context);
|
|
22
|
+
|
|
23
|
+
let initialLoadFinished = false;
|
|
24
|
+
const mcp = {
|
|
25
|
+
waitForInitialLoad() {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
initialLoadFinished = true;
|
|
29
|
+
resolve();
|
|
30
|
+
}, 20_000);
|
|
31
|
+
timer.unref();
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
(async () => {
|
|
37
|
+
const startedAt = performance.now();
|
|
38
|
+
await context.waitForInitialMcp(mcp, undefined);
|
|
39
|
+
const elapsedMs = performance.now() - startedAt;
|
|
40
|
+
|
|
41
|
+
assert.ok(elapsedMs >= 2_300, `wait budget returned too early: ${elapsedMs} ms`);
|
|
42
|
+
assert.ok(elapsedMs < 3_200, `slow optional MCP blocked the turn: ${elapsedMs} ms`);
|
|
43
|
+
assert.equal(initialLoadFinished, false, 'the slow MCP must continue loading in the background');
|
|
44
|
+
process.stdout.write(`MCP startup wait regression: PASS (${Math.round(elapsedMs)} ms)\n`);
|
|
45
|
+
})().catch((error) => {
|
|
46
|
+
process.stderr.write(`${error.stack || error}\n`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const { installManagedAgentSpinePlugin } = require('../bin/plugin-bootstrap');
|
|
9
|
+
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'blun-plugin-startup-'));
|
|
11
|
+
const packageRoot = path.join(root, 'package');
|
|
12
|
+
const sourceRoot = path.join(packageRoot, 'agent-spine-plugin');
|
|
13
|
+
const profileA = path.join(root, 'profiles', 'a');
|
|
14
|
+
const profileB = path.join(root, 'profiles', 'b');
|
|
15
|
+
|
|
16
|
+
fs.mkdirSync(path.join(sourceRoot, 'src'), { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(sourceRoot, 'package.json'), '{"name":"agent-spine"}\n');
|
|
18
|
+
fs.writeFileSync(path.join(sourceRoot, 'src', 'mcp.js'), 'export const ready = true;\n');
|
|
19
|
+
|
|
20
|
+
const originalReadFileSync = fs.readFileSync;
|
|
21
|
+
let sourceReads = 0;
|
|
22
|
+
fs.readFileSync = function countedReadFileSync(filePath, ...args) {
|
|
23
|
+
const absolutePath = path.resolve(String(filePath));
|
|
24
|
+
if (absolutePath === sourceRoot || absolutePath.startsWith(`${sourceRoot}${path.sep}`)) {
|
|
25
|
+
sourceReads += 1;
|
|
26
|
+
}
|
|
27
|
+
return originalReadFileSync.call(this, filePath, ...args);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const first = installManagedAgentSpinePlugin({ packageRoot, blunDir: profileA });
|
|
32
|
+
installManagedAgentSpinePlugin({ packageRoot, blunDir: profileA });
|
|
33
|
+
installManagedAgentSpinePlugin({ packageRoot, blunDir: profileB });
|
|
34
|
+
installManagedAgentSpinePlugin({ packageRoot, blunDir: profileB });
|
|
35
|
+
|
|
36
|
+
assert.equal(
|
|
37
|
+
sourceReads,
|
|
38
|
+
2,
|
|
39
|
+
'one launcher process must read each managed source file only once',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
fs.writeFileSync(path.join(first.pluginRoot, 'src', 'mcp.js'), 'tampered\n');
|
|
43
|
+
assert.throws(
|
|
44
|
+
() => installManagedAgentSpinePlugin({ packageRoot, blunDir: profileA }),
|
|
45
|
+
/digest mismatch/,
|
|
46
|
+
'source caching must not weaken installed-tree verification',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
process.stdout.write('Plugin startup regression gate: PASS\n');
|
|
50
|
+
} finally {
|
|
51
|
+
fs.readFileSync = originalReadFileSync;
|
|
52
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
53
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { enqueueTelegramAddressed } = require('../bin/telegram-addressed-priority.cjs');
|
|
5
|
+
const { enqueueTelegramBotPriority } = require('../bin/telegram-bot-priority.cjs');
|
|
6
|
+
const { enqueueTelegramDirect } = require('../bin/telegram-direct-focus-policy.cjs');
|
|
7
|
+
const { enqueueTelegramUrgent } = require('../bin/telegram-urgent-policy.cjs');
|
|
8
|
+
|
|
9
|
+
const bundle = fs.readFileSync(path.join(__dirname, '..', 'blun.mjs'), 'utf8');
|
|
10
|
+
|
|
11
|
+
function between(start, end) {
|
|
12
|
+
const startIndex = bundle.indexOf(start);
|
|
13
|
+
assert.notEqual(startIndex, -1, `missing bundle marker: ${start}`);
|
|
14
|
+
const endIndex = bundle.indexOf(end, startIndex + start.length);
|
|
15
|
+
assert.notEqual(endIndex, -1, `missing bundle marker: ${end}`);
|
|
16
|
+
return bundle.slice(startIndex, endIndex);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function method(name, nextName) {
|
|
20
|
+
const source = between(`\n\t${name}(`, `\n\t${nextName}(`).trim();
|
|
21
|
+
return Function(`"use strict"; return ({${source}}).${name};`)();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ctrlS = between('\n\t\teditor.onCtrlS = () => {', '\n\t\teditor.onCtrlB =');
|
|
25
|
+
assert.match(ctrlS, /host\.flushQueuedMessages\(/);
|
|
26
|
+
assert.doesNotMatch(ctrlS, /toggleTodoPanelExpansion|shortcut_todo_expand/);
|
|
27
|
+
|
|
28
|
+
const escape = between('\n\t\teditor.onEscape =', '\n\t\teditor.onShiftTab =');
|
|
29
|
+
const releaseIndex = escape.indexOf('host.releaseNextQueuedMessage()');
|
|
30
|
+
const cancelIndex = escape.indexOf('host.markCurrentRunCancelledByUser()');
|
|
31
|
+
assert.ok(releaseIndex >= 0, 'Escape must release queued work');
|
|
32
|
+
assert.ok(cancelIndex > releaseIndex, 'Escape must release queued work before cancelling the run');
|
|
33
|
+
|
|
34
|
+
const releaseNextQueuedMessage = method('releaseNextQueuedMessage', 'markCurrentRunCancelledByUser');
|
|
35
|
+
const releaseHost = {
|
|
36
|
+
queueSteerInFlight: undefined,
|
|
37
|
+
queueFlushBatchRemaining: 0,
|
|
38
|
+
state: {
|
|
39
|
+
queuedMessages: [{ text: 'one' }, { text: 'two' }, { text: 'three' }],
|
|
40
|
+
ui: { requestRender() {} },
|
|
41
|
+
},
|
|
42
|
+
steerQueueFlushPrefixCalls: 0,
|
|
43
|
+
updateQueueDisplayCalls: 0,
|
|
44
|
+
steerQueueFlushPrefix() { this.steerQueueFlushPrefixCalls += 1; },
|
|
45
|
+
updateQueueDisplay() { this.updateQueueDisplayCalls += 1; },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
assert.equal(releaseNextQueuedMessage.call(releaseHost), true);
|
|
49
|
+
assert.equal(releaseHost.queueFlushBatchRemaining, 1);
|
|
50
|
+
assert.equal(releaseNextQueuedMessage.call(releaseHost), true);
|
|
51
|
+
assert.equal(releaseHost.queueFlushBatchRemaining, 2);
|
|
52
|
+
assert.equal(releaseNextQueuedMessage.call(releaseHost), true);
|
|
53
|
+
assert.equal(releaseHost.queueFlushBatchRemaining, 3);
|
|
54
|
+
assert.equal(releaseNextQueuedMessage.call(releaseHost), false);
|
|
55
|
+
assert.equal(releaseHost.queueFlushBatchRemaining, 3);
|
|
56
|
+
assert.equal(releaseHost.steerQueueFlushPrefixCalls, 3);
|
|
57
|
+
|
|
58
|
+
const flushQueuedMessages = method('flushQueuedMessages', 'steerQueueFlushPrefix');
|
|
59
|
+
const flushHost = {
|
|
60
|
+
queueSteerInFlight: { items: [{ text: 'already accepted' }] },
|
|
61
|
+
queueFlushBatchRemaining: 0,
|
|
62
|
+
state: {
|
|
63
|
+
queuedMessages: [{ text: 'one' }, { text: 'two' }, { text: 'three' }],
|
|
64
|
+
editor: { setText() {}, inputMode: 'prompt' },
|
|
65
|
+
},
|
|
66
|
+
steerQueueFlushPrefixCalls: 0,
|
|
67
|
+
scheduleQueueDrainCalls: 0,
|
|
68
|
+
steerQueueFlushPrefix() { this.steerQueueFlushPrefixCalls += 1; },
|
|
69
|
+
scheduleQueueDrain() { this.scheduleQueueDrainCalls += 1; },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
flushQueuedMessages.call(flushHost, '', 'prompt');
|
|
73
|
+
assert.equal(flushHost.queueFlushBatchRemaining, 4);
|
|
74
|
+
assert.equal(flushHost.steerQueueFlushPrefixCalls, 1);
|
|
75
|
+
assert.equal(flushHost.scheduleQueueDrainCalls, 1);
|
|
76
|
+
|
|
77
|
+
const commitQueuedSteer = method('commitQueuedSteer', 'recoverRejectedActiveSteer');
|
|
78
|
+
let commits = 0;
|
|
79
|
+
const inFlight = {
|
|
80
|
+
items: [{ text: 'telegram message' }],
|
|
81
|
+
confirmationTimer: undefined,
|
|
82
|
+
onCommit() { commits += 1; },
|
|
83
|
+
};
|
|
84
|
+
const commitHost = {
|
|
85
|
+
queueSteerInFlight: inFlight,
|
|
86
|
+
updateQueueDisplay() {},
|
|
87
|
+
state: { ui: { requestRender() {} } },
|
|
88
|
+
steerQueueFlushPrefix() {},
|
|
89
|
+
scheduleQueueDrain() {},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
commitQueuedSteer.call(commitHost, inFlight);
|
|
93
|
+
commitQueuedSteer.call(commitHost, inFlight);
|
|
94
|
+
assert.equal(commits, 1);
|
|
95
|
+
assert.equal(commitHost.queueSteerInFlight, undefined);
|
|
96
|
+
|
|
97
|
+
const acceptQueuedSteer = method('acceptQueuedSteer', 'commitQueuedSteerIfReady');
|
|
98
|
+
let acceptedCommits = 0;
|
|
99
|
+
const acceptedInFlight = {
|
|
100
|
+
items: [{ channelDeliveryTrace: { messageId: '63508' } }],
|
|
101
|
+
accepted: false,
|
|
102
|
+
stepStarted: false,
|
|
103
|
+
turnEnded: false,
|
|
104
|
+
onCommit() { acceptedCommits += 1; },
|
|
105
|
+
};
|
|
106
|
+
const acceptedHost = {
|
|
107
|
+
queueSteerInFlight: acceptedInFlight,
|
|
108
|
+
traced: [],
|
|
109
|
+
traceTelegramDelivery(event) { this.traced.push(event); },
|
|
110
|
+
commitQueuedSteer(item) {
|
|
111
|
+
assert.equal(item, acceptedInFlight);
|
|
112
|
+
if (this.queueSteerInFlight !== item) return;
|
|
113
|
+
this.queueSteerInFlight = undefined;
|
|
114
|
+
item.onCommit();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
assert.equal(acceptQueuedSteer.call(acceptedHost, acceptedInFlight, {
|
|
119
|
+
accepted: true,
|
|
120
|
+
duplicate: false,
|
|
121
|
+
turnId: 77,
|
|
122
|
+
}, true), true);
|
|
123
|
+
assert.equal(acceptedCommits, 1, 'durably accepted steer must commit before a delayed step event');
|
|
124
|
+
assert.equal(acceptedHost.queueSteerInFlight, undefined);
|
|
125
|
+
assert.equal(acceptedHost.traced.length, 1);
|
|
126
|
+
assert.equal(acceptedHost.traced[0].stage, 'accepted');
|
|
127
|
+
|
|
128
|
+
const recoverRejectedActiveSteer = method('recoverRejectedActiveSteer', 'quarantineQueuedSteer');
|
|
129
|
+
let restored = 0;
|
|
130
|
+
const rejectedInFlight = {
|
|
131
|
+
items: [{ text: 'keep me queued', channelDeliveryFailures: 2 }],
|
|
132
|
+
onRestore() { restored += 1; },
|
|
133
|
+
};
|
|
134
|
+
const rejectedHost = {
|
|
135
|
+
queueSteerInFlight: rejectedInFlight,
|
|
136
|
+
state: { queuedMessages: [], ui: { requestRender() {} } },
|
|
137
|
+
traced: [],
|
|
138
|
+
traceTelegramDelivery(event) { this.traced.push(event); },
|
|
139
|
+
updateQueueDisplay() {},
|
|
140
|
+
streamingUI: { hasActiveTurn() { return true; } },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
assert.equal(recoverRejectedActiveSteer.call(rejectedHost, rejectedInFlight), false);
|
|
144
|
+
assert.equal(restored, 1);
|
|
145
|
+
assert.equal(rejectedHost.state.queuedMessages.length, 1, 'rejected steer must remain queued');
|
|
146
|
+
assert.equal(rejectedHost.state.queuedMessages[0].text, 'keep me queued');
|
|
147
|
+
assert.equal('channelDeliveryFailures' in rejectedHost.state.queuedMessages[0], false);
|
|
148
|
+
|
|
149
|
+
const channelDelivery = between('\n\tasync deliverQueuedChannelHead() {', '\n\tinstallChannelReplyGuard(');
|
|
150
|
+
assert.match(
|
|
151
|
+
channelDelivery,
|
|
152
|
+
/if \(!result\.accepted\) return this\.recoverRejectedActiveSteer\(inFlight\);[\s\S]*return this\.acceptQueuedSteer\(inFlight, result, true\);/,
|
|
153
|
+
'channel delivery must commit a durably accepted active steer',
|
|
154
|
+
);
|
|
155
|
+
assert.doesNotMatch(
|
|
156
|
+
channelDelivery,
|
|
157
|
+
/channel steer confirmation timed out|CHANNEL_STEER_CONFIRM_TIMEOUT_MS/,
|
|
158
|
+
'durably accepted channel messages must not be restored by a wall-clock timeout',
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const ctrlSDelivery = between('\n\tsteerQueueFlushPrefix() {', '\n\tdrainOneQueuedMessage() {');
|
|
162
|
+
assert.match(
|
|
163
|
+
ctrlSDelivery,
|
|
164
|
+
/if \(!result\.accepted\) this\.recoverRejectedActiveSteer\(inFlight\);[\s\S]*else this\.acceptQueuedSteer\(inFlight, result\);/,
|
|
165
|
+
'Ctrl+S delivery must use the same durable acceptance rule',
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const priorityQueue = [{ id: 'normal-a' }, { id: 'normal-b' }];
|
|
169
|
+
enqueueTelegramBotPriority(priorityQueue, { id: 'bot-a', channelBotPriority: true });
|
|
170
|
+
enqueueTelegramBotPriority(priorityQueue, { id: 'bot-b', channelBotPriority: true });
|
|
171
|
+
enqueueTelegramAddressed(priorityQueue, { id: 'addressed-a', channelAddressed: true });
|
|
172
|
+
enqueueTelegramAddressed(priorityQueue, { id: 'addressed-b', channelAddressed: true });
|
|
173
|
+
enqueueTelegramDirect(priorityQueue, { id: 'direct-a', channelDirect: true });
|
|
174
|
+
enqueueTelegramDirect(priorityQueue, { id: 'direct-b', channelDirect: true });
|
|
175
|
+
enqueueTelegramUrgent(priorityQueue, { id: 'urgent-a', channelUrgent: true });
|
|
176
|
+
enqueueTelegramUrgent(priorityQueue, { id: 'urgent-b', channelUrgent: true });
|
|
177
|
+
assert.deepEqual(
|
|
178
|
+
priorityQueue.map((item) => item.id),
|
|
179
|
+
[
|
|
180
|
+
'urgent-a', 'urgent-b',
|
|
181
|
+
'direct-a', 'direct-b',
|
|
182
|
+
'addressed-a', 'addressed-b',
|
|
183
|
+
'bot-a', 'bot-b',
|
|
184
|
+
'normal-a', 'normal-b',
|
|
185
|
+
],
|
|
186
|
+
'Telegram queue priority must be urgent, direct, addressed, bot, normal with stable FIFO inside each class',
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
console.log('queue controls regression: PASS');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const bundlePath = process.env.BLUN_BUNDLE_UNDER_TEST
|
|
9
|
+
? path.resolve(process.env.BLUN_BUNDLE_UNDER_TEST)
|
|
10
|
+
: path.join(__dirname, '..', 'blun.mjs');
|
|
11
|
+
const bundle = fs.readFileSync(bundlePath, 'utf8');
|
|
12
|
+
|
|
13
|
+
function between(start, end) {
|
|
14
|
+
const startIndex = bundle.indexOf(start);
|
|
15
|
+
assert.notEqual(startIndex, -1, `missing bundle marker: ${start}`);
|
|
16
|
+
const endIndex = bundle.indexOf(end, startIndex + start.length);
|
|
17
|
+
assert.notEqual(endIndex, -1, `missing bundle marker: ${end}`);
|
|
18
|
+
return bundle.slice(startIndex, endIndex);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function compileMethod(name, nextName, dependencies = {}) {
|
|
22
|
+
const source = between(`\n\t\t${name}(`, `\n\t\t${nextName}(`).trim();
|
|
23
|
+
const names = Object.keys(dependencies);
|
|
24
|
+
const values = Object.values(dependencies);
|
|
25
|
+
return Function(...names, `"use strict"; return ({${source}}).${name};`)(...values);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveStepEventUuid(openSteps, event) {
|
|
29
|
+
if (event.stepUuid !== undefined) return event.stepUuid;
|
|
30
|
+
let latest;
|
|
31
|
+
for (const uuid of openSteps.keys()) latest = uuid;
|
|
32
|
+
return latest;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveCompletedStepUuid(openSteps, event) {
|
|
36
|
+
if (event.uuid !== undefined) return event.uuid;
|
|
37
|
+
let latest;
|
|
38
|
+
for (const uuid of openSteps.keys()) latest = uuid;
|
|
39
|
+
return latest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const recoverMissingOpenStep = compileMethod(
|
|
43
|
+
'recoverMissingOpenStep',
|
|
44
|
+
'appendLoopEvent',
|
|
45
|
+
{ resolveStepEventUuid },
|
|
46
|
+
);
|
|
47
|
+
const appendLoopEvent = compileMethod(
|
|
48
|
+
'appendLoopEvent',
|
|
49
|
+
'appendMessage',
|
|
50
|
+
{
|
|
51
|
+
projectLoopEventForRecord: (event) => event,
|
|
52
|
+
resolveCompletedStepUuid,
|
|
53
|
+
createToolMessage: (toolCallId, content) => ({ toolCallId, content, toolCalls: [] }),
|
|
54
|
+
toolResultOutputForModel: (result) => result.output,
|
|
55
|
+
estimateTokensForMessages: () => 0,
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const records = [];
|
|
60
|
+
const warnings = [];
|
|
61
|
+
const host = {
|
|
62
|
+
_history: [],
|
|
63
|
+
_tokenCount: 0,
|
|
64
|
+
tokenCountCoveredMessageCount: 0,
|
|
65
|
+
openSteps: new Map(),
|
|
66
|
+
pendingToolResultIds: new Set(),
|
|
67
|
+
agent: {
|
|
68
|
+
records: {
|
|
69
|
+
restoring: { time: Date.now() },
|
|
70
|
+
logRecord(record) { records.push(record); },
|
|
71
|
+
},
|
|
72
|
+
log: { warn(message, fields) { warnings.push({ message, fields }); } },
|
|
73
|
+
},
|
|
74
|
+
recoverMissingOpenStep,
|
|
75
|
+
pushHistory(...messages) { this._history.push(...messages); },
|
|
76
|
+
markPendingTokenEstimateDirty() {},
|
|
77
|
+
flushDeferredMessagesIfToolExchangeClosed() {},
|
|
78
|
+
closePendingToolResults() { return []; },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Exact shape measured after context.apply_compaction: no step.begin survives,
|
|
82
|
+
// then text, a tool call/result pair, and step.end continue the interrupted turn.
|
|
83
|
+
appendLoopEvent.call(host, { type: 'content.part', part: { type: 'text', text: 'continued after compaction' } });
|
|
84
|
+
appendLoopEvent.call(host, { type: 'tool.call', toolCallId: 'tool-1', name: 'Read', args: { path: 'SPEC.md' } });
|
|
85
|
+
appendLoopEvent.call(host, { type: 'tool.result', toolCallId: 'tool-1', result: { output: 'ok', isError: false } });
|
|
86
|
+
appendLoopEvent.call(host, { type: 'step.end', contextTokens: 100 });
|
|
87
|
+
|
|
88
|
+
assert.equal(warnings.length, 1, 'one missing step must produce one bounded repair warning');
|
|
89
|
+
assert.equal(warnings[0].fields.eventType, 'content.part');
|
|
90
|
+
assert.equal(warnings[0].fields.restoring, true);
|
|
91
|
+
assert.equal(host._history.length, 2, 'repair must retain the assistant continuation and its tool result');
|
|
92
|
+
assert.deepEqual(host._history[0].content, [{ type: 'text', text: 'continued after compaction' }]);
|
|
93
|
+
assert.equal(host._history[0].toolCalls.length, 1);
|
|
94
|
+
assert.equal(host._history[0].toolCalls[0].id, 'tool-1');
|
|
95
|
+
assert.equal(host._history[1].toolCallId, 'tool-1');
|
|
96
|
+
assert.equal(host.pendingToolResultIds.size, 0);
|
|
97
|
+
assert.equal(host.openSteps.size, 0);
|
|
98
|
+
assert.equal(records.filter((record) => record.type === 'context.append_loop_event').length, 4);
|
|
99
|
+
|
|
100
|
+
process.stdout.write('resume-replay-regression PASS\n');
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const bundle = fs.readFileSync(path.join(__dirname, '..', 'blun.mjs'), 'utf8');
|
|
9
|
+
|
|
10
|
+
function between(start, end) {
|
|
11
|
+
const startIndex = bundle.indexOf(start);
|
|
12
|
+
assert.notEqual(startIndex, -1, `missing bundle marker: ${start}`);
|
|
13
|
+
const endIndex = bundle.indexOf(end, startIndex + start.length);
|
|
14
|
+
assert.notEqual(endIndex, -1, `missing bundle marker: ${end}`);
|
|
15
|
+
return bundle.slice(startIndex, endIndex);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function method(name, nextName) {
|
|
19
|
+
const source = between(`\n\t${name}(`, `\n\t${nextName}(`).trim();
|
|
20
|
+
return Function(`"use strict"; return ({${source}}).${name};`)();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const scheduleBridgeRestart = method('scheduleBridgeRestart', 'ensureBridgeRunning');
|
|
25
|
+
const host = {
|
|
26
|
+
stopped: false,
|
|
27
|
+
activeOwner: true,
|
|
28
|
+
bridgeRestartTimer: undefined,
|
|
29
|
+
bridgeRestartDelayMs: 5,
|
|
30
|
+
ownsChannel() { return true; },
|
|
31
|
+
starts: 0,
|
|
32
|
+
ensureBridgeRunning() { this.starts += 1; },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
scheduleBridgeRestart.call(host);
|
|
36
|
+
const firstTimer = host.bridgeRestartTimer;
|
|
37
|
+
assert.notEqual(firstTimer, undefined);
|
|
38
|
+
scheduleBridgeRestart.call(host);
|
|
39
|
+
assert.equal(host.bridgeRestartTimer, firstTimer, 'restart scheduling must be deduplicated');
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
41
|
+
assert.equal(host.starts, 1, 'a dead owned bridge must be restarted once');
|
|
42
|
+
assert.equal(host.bridgeRestartTimer, undefined);
|
|
43
|
+
|
|
44
|
+
host.stopped = true;
|
|
45
|
+
scheduleBridgeRestart.call(host);
|
|
46
|
+
assert.equal(host.bridgeRestartTimer, undefined, 'stopped controllers must not restart bridges');
|
|
47
|
+
|
|
48
|
+
const ensureBridgeRunning = between('\n\tensureBridgeRunning() {', '\n\tresolveBridgeEntry() {');
|
|
49
|
+
assert.match(ensureBridgeRunning, /child\.once\("exit",[\s\S]*this\.scheduleBridgeRestart\(\)/);
|
|
50
|
+
assert.match(ensureBridgeRunning, /child\.once\("error",[\s\S]*this\.scheduleBridgeRestart\(\)/);
|
|
51
|
+
const beginStop = between('\n\tbeginStop() {', '\n\tasync stopOwnedBridge(');
|
|
52
|
+
assert.match(beginStop, /clearTimeout\(this\.bridgeRestartTimer\)/);
|
|
53
|
+
|
|
54
|
+
process.stdout.write('telegram-bridge-watchdog PASS\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch((error) => {
|
|
58
|
+
console.error(error);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
});
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
+
const { enforceSingleTodoWritePerStep } = require('../bin/todo-list-turn-policy.cjs');
|
|
6
7
|
|
|
7
|
-
const bundlePath =
|
|
8
|
+
const bundlePath = process.env.BLUN_BUNDLE_UNDER_TEST
|
|
9
|
+
? path.resolve(process.env.BLUN_BUNDLE_UNDER_TEST)
|
|
10
|
+
: path.resolve(__dirname, '..', 'blun.mjs');
|
|
8
11
|
const bundle = fs.readFileSync(bundlePath, 'utf8');
|
|
9
12
|
|
|
10
13
|
function assert(condition, message) {
|
|
@@ -23,5 +26,53 @@ assert(
|
|
|
23
26
|
/isIdle:\s*\(\)\s*=>\s*!agent\.turn\.hasActiveTurn/.test(bundle),
|
|
24
27
|
'TODO_LOOP_REGRESSION: expected idle-only session loop scheduler wiring is missing',
|
|
25
28
|
);
|
|
29
|
+
assert(
|
|
30
|
+
/todos\.filter\(\(todo\)\s*=>\s*todo\.status\s*!==\s*["']done["']\)/.test(bundle),
|
|
31
|
+
'TODO_LIFECYCLE_REGRESSION: completed items must be pruned from the stored visible list',
|
|
32
|
+
);
|
|
33
|
+
assert(
|
|
34
|
+
/const PENDING_TEXT\s*=\s*["']#aeb8c6["']/.test(bundle)
|
|
35
|
+
&& !/const PENDING_TEXT\s*=\s*["']#2a3550["']/.test(bundle),
|
|
36
|
+
'TODO_UI_REGRESSION: pending markers must remain visible on ANSI16 terminals',
|
|
37
|
+
);
|
|
38
|
+
assert(
|
|
39
|
+
/activeGoal\s*===\s*null\s*&&\s*!startsTrackedList/.test(bundle)
|
|
40
|
+
&& /The first TodoList must contain at least one item, exactly one in_progress item/.test(bundle),
|
|
41
|
+
'TODO_INITIAL_STATE_REGRESSION: a new non-goal TodoList must not accept an all-pending list',
|
|
42
|
+
);
|
|
43
|
+
assert(
|
|
44
|
+
/enforceSingleTodoWritePerStep\(ctx\)/.test(bundle),
|
|
45
|
+
'TODO_PARALLEL_REGRESSION: per-step TodoList write guard is not wired',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const calls = [
|
|
49
|
+
{ id: 'todo-first', name: 'TodoList' },
|
|
50
|
+
{ id: 'todo-second', name: 'TodoList' },
|
|
51
|
+
];
|
|
52
|
+
assert(
|
|
53
|
+
enforceSingleTodoWritePerStep({
|
|
54
|
+
toolCall: calls[0],
|
|
55
|
+
toolCalls: calls,
|
|
56
|
+
args: { todos: [{ title: 'first', status: 'in_progress' }] },
|
|
57
|
+
}) === undefined,
|
|
58
|
+
'TODO_PARALLEL_REGRESSION: first TodoList write must remain executable',
|
|
59
|
+
);
|
|
60
|
+
const duplicateWrite = enforceSingleTodoWritePerStep({
|
|
61
|
+
toolCall: calls[1],
|
|
62
|
+
toolCalls: calls,
|
|
63
|
+
args: { todos: [{ title: 'second', status: 'in_progress' }] },
|
|
64
|
+
});
|
|
65
|
+
assert(
|
|
66
|
+
duplicateWrite?.block === true,
|
|
67
|
+
'TODO_PARALLEL_REGRESSION: later TodoList writes in the same step must be blocked',
|
|
68
|
+
);
|
|
69
|
+
assert(
|
|
70
|
+
enforceSingleTodoWritePerStep({
|
|
71
|
+
toolCall: { id: 'todo-read', name: 'TodoList' },
|
|
72
|
+
toolCalls: calls,
|
|
73
|
+
args: {},
|
|
74
|
+
}) === undefined,
|
|
75
|
+
'TODO_PARALLEL_REGRESSION: read-only TodoList queries must not be blocked',
|
|
76
|
+
);
|
|
26
77
|
|
|
27
78
|
process.stdout.write('todo-loop-regression PASS\n');
|