blun-king-cli 9.1.536 → 9.1.561
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/LIESMICH.txt +13 -869
- package/README.md +41 -833
- package/bin/assistant-message-offload-policy.cjs +3 -1
- package/bin/compaction-transaction-policy.cjs +122 -0
- package/bin/context-performance-policy.cjs +2 -5
- package/bin/context-pressure-policy.cjs +20 -0
- package/bin/cron-run-output.cjs +45 -0
- package/bin/cron-run-store.cjs +145 -0
- package/bin/default-model-output-budget-policy.cjs +28 -0
- package/bin/durable-task-resume-policy.cjs +130 -0
- package/bin/durable-task-resume-runtime.cjs +117 -0
- package/bin/durable-task-resume-store.cjs +88 -0
- package/bin/editable-tool-approval-policy.cjs +540 -0
- package/bin/editable-tool-approval-runtime.cjs +99 -0
- package/bin/file-observation-policy.cjs +133 -0
- package/bin/html-to-research-markdown.cjs +146 -0
- package/bin/launcher-runtime.js +0 -1
- package/bin/micro-compaction-policy.cjs +64 -0
- package/bin/mnemo-connect-heartbeat.cjs +1 -3
- package/bin/programmatic-tool-runtime.mjs +330 -4
- package/bin/read-continuation-policy.cjs +36 -5
- package/bin/retry-checkpoint-policy.cjs +13 -0
- package/bin/scoped-cron-run-policy.cjs +358 -0
- package/bin/session-checkpoint-policy.cjs +25 -0
- package/bin/startup-preferences.cjs +4 -3
- package/bin/structured-agent-swarm-output.cjs +325 -0
- package/bin/subagent-context-fork-policy.cjs +155 -0
- package/bin/subagent-skill-policy.cjs +204 -0
- package/bin/telegram-approval-relay.cjs +2 -1
- package/bin/tool-file-persistence.cjs +141 -0
- package/bin/tool-result-offload-policy.cjs +25 -33
- package/bin/turn-thinking-policy.cjs +2 -26
- package/bin/update-notice.js +30 -18
- package/bin/user-message-offload-policy.cjs +3 -1
- package/blun.mjs +1564 -640
- package/codebase-index/README.md +12 -0
- package/codebase-index/codebase_index.py +129 -18
- package/package.json +23 -58
- package/telegram-plugin/bin/telegram-mnemo-capture.cjs +1 -3
- package/telegram-plugin/bin/telegram-typing-keepalive.cjs +89 -0
- package/telegram-plugin/dist/bridge.mjs +8 -1
- package/CHANGELOG.md +0 -321
- package/agent-spine-plugin/CHANGELOG.md +0 -406
- package/agent-spine-plugin/CONTRIBUTING.md +0 -52
- package/agent-spine-plugin/README.md +0 -344
- package/agent-spine-plugin/SECURITY.md +0 -47
- package/agent-spine-plugin/docs/acceptance.md +0 -61
- package/agent-spine-plugin/docs/architecture.md +0 -183
- package/agent-spine-plugin/docs/attention.md +0 -121
- package/agent-spine-plugin/docs/automatic-continuity.md +0 -79
- package/agent-spine-plugin/docs/channel-runtime.md +0 -92
- package/agent-spine-plugin/docs/coordination.md +0 -138
- package/agent-spine-plugin/docs/feed-transport.md +0 -99
- package/agent-spine-plugin/docs/gateway-runtime.md +0 -116
- package/agent-spine-plugin/docs/harness-reference.md +0 -45
- package/agent-spine-plugin/docs/host-integration.md +0 -129
- package/agent-spine-plugin/docs/https-transport.md +0 -116
- package/agent-spine-plugin/docs/learning.md +0 -133
- package/agent-spine-plugin/docs/object-transport.md +0 -93
- package/agent-spine-plugin/docs/peer-transport.md +0 -88
- package/agent-spine-plugin/docs/preflight-recall.md +0 -69
- package/agent-spine-plugin/docs/preservation-contract.md +0 -53
- package/agent-spine-plugin/docs/quality-gates.md +0 -50
- package/agent-spine-plugin/docs/relationships.md +0 -73
- package/agent-spine-plugin/docs/releasing.md +0 -83
- package/agent-spine-plugin/docs/roadmap.md +0 -307
- package/agent-spine-plugin/docs/selfstarter.md +0 -88
- package/agent-spine-plugin/docs/session-briefing.md +0 -74
- package/agent-spine-plugin/docs/shared-memory.md +0 -259
- package/agent-spine-plugin/docs/source-roots.md +0 -86
- package/agent-spine-plugin/docs/sqlite-transport.md +0 -76
- package/agent-spine-plugin/scripts/check-hosts.js +0 -195
- package/agent-spine-plugin/scripts/check-install.js +0 -569
- package/agent-spine-plugin/scripts/check-syntax.js +0 -29
- package/agent-spine-plugin/scripts/github-actions.js +0 -11
- package/agent-spine-plugin/scripts/release-check.js +0 -128
- package/agent-spine-plugin/scripts/run-acceptance.js +0 -19
- package/agent-spine-plugin/scripts/run-checks.js +0 -46
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +0 -73
- package/agent-spine-plugin/spine-example/1-identity.md +0 -12
- package/agent-spine-plugin/spine-example/2-voice.md +0 -6
- package/agent-spine-plugin/spine-example/3-conduct.md +0 -8
- package/agent-spine-plugin/spine-example/4-history.md +0 -4
- package/bin/empty-response-retry-policy.cjs +0 -29
- package/bin/fredrik-glm-provider.cjs +0 -256
- package/bin/package-regression-policy.cjs +0 -77
- package/fredrik-glm-profile.toml.example +0 -26
- package/release-planned-removals.json +0 -15
- package/scripts/check-active-profile-plugin-startup.js +0 -36
- package/scripts/check-active-work-steer-regression.js +0 -46
- package/scripts/check-approval-observability-regression.js +0 -111
- package/scripts/check-approval-queue-shortcuts-regression.js +0 -65
- package/scripts/check-bundled-agent-spine-regression.js +0 -48
- package/scripts/check-codebase-search-packaging-regression.js +0 -92
- package/scripts/check-copy-command-regression.js +0 -74
- package/scripts/check-current-turn-read-pin-mutation-regression.js +0 -72
- package/scripts/check-current-turn-read-pin-regression.js +0 -94
- package/scripts/check-deepseek-native-max-regression.js +0 -49
- package/scripts/check-empty-response-effort-downgrade-regression.js +0 -48
- package/scripts/check-fredrik-glm-mutation-regression.js +0 -18
- package/scripts/check-fredrik-glm-regression.js +0 -169
- package/scripts/check-historical-tool-result-preview-regression.js +0 -77
- package/scripts/check-history-pressure-offload-regression.js +0 -77
- package/scripts/check-mcp-startup-wait-budget.js +0 -48
- package/scripts/check-package-regression.js +0 -38
- package/scripts/check-plugin-startup-regression.js +0 -53
- package/scripts/check-programmatic-context-isolation-regression.js +0 -193
- package/scripts/check-programmatic-tool-regression.js +0 -294
- package/scripts/check-queue-controls-regression.js +0 -189
- package/scripts/check-release-metadata.js +0 -103
- package/scripts/check-reload-agent-spine-regression.js +0 -76
- package/scripts/check-resume-replay-regression.js +0 -102
- package/scripts/check-session-cancel-regression.js +0 -43
- package/scripts/check-session-picker-resume-metrics-regression.js +0 -97
- package/scripts/check-session-start-hook-context-regression.js +0 -228
- package/scripts/check-shell-terminal-isolation-regression.js +0 -81
- package/scripts/check-slash-escape-regression.js +0 -89
- package/scripts/check-startup-swarm-command-regression.js +0 -24
- package/scripts/check-structured-subagent-output-regression.js +0 -331
- package/scripts/check-telegram-bridge-watchdog.js +0 -60
- package/scripts/check-telegram-direct-work-resume-regression.js +0 -53
- package/scripts/check-telegram-loop-exactly-once-regression.js +0 -71
- package/scripts/check-todo-loop-regression.js +0 -78
- package/scripts/check-todo-progress-regression.js +0 -416
- package/scripts/check-todo-recovery-catalog-regression.js +0 -50
- package/scripts/check-tool-schema-capacity-regression.js +0 -40
- package/scripts/programmatic-tool-runtime.test.mjs +0 -365
- package/scripts/structured-subagent-output.test.cjs +0 -170
- /package/{scripts → bin}/fix-node-pty-perms.js +0 -0
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
hasHistoricalOffloadPressure,
|
|
5
5
|
historyTextChars,
|
|
6
6
|
} = require('./history-offload-pressure-policy.cjs');
|
|
7
|
+
const { hasContextPressure } = require('./context-pressure-policy.cjs');
|
|
7
8
|
|
|
8
9
|
const ASSISTANT_MESSAGE_MAX_CHARS = 4_000;
|
|
9
10
|
const ASSISTANT_MESSAGE_KEEP_RECENT_MESSAGES = 20;
|
|
@@ -50,7 +51,8 @@ function shouldOffloadHistoricalAssistantMessage(options = {}) {
|
|
|
50
51
|
|| historyIndex >= historyLength
|
|
51
52
|
) return false;
|
|
52
53
|
const cutoff = Math.max(0, historyLength - ASSISTANT_MESSAGE_KEEP_RECENT_MESSAGES);
|
|
53
|
-
return
|
|
54
|
+
return hasContextPressure(options)
|
|
55
|
+
&& hasHistoricalOffloadPressure({ historyLength, historyChars })
|
|
54
56
|
&& historyIndex < cutoff
|
|
55
57
|
&& textChars > ASSISTANT_MESSAGE_MAX_CHARS;
|
|
56
58
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
function positiveInteger(value, label) {
|
|
6
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
7
|
+
throw new TypeError(`${label} must be a non-negative safe integer`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toolCallId(call) {
|
|
13
|
+
return typeof call?.id === 'string' && call.id.length > 0 ? call.id : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertBalancedToolPairs(messages, label = 'compaction history') {
|
|
17
|
+
const pending = new Set();
|
|
18
|
+
for (const message of messages) {
|
|
19
|
+
if (message?.role === 'assistant') {
|
|
20
|
+
for (const call of message.toolCalls ?? []) {
|
|
21
|
+
const id = toolCallId(call);
|
|
22
|
+
if (id === undefined) throw new Error(`${label} contains a tool call without an id`);
|
|
23
|
+
if (pending.has(id)) throw new Error(`${label} contains duplicate tool call id ${id}`);
|
|
24
|
+
pending.add(id);
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (message?.role !== 'tool') continue;
|
|
29
|
+
const id = typeof message.toolCallId === 'string' ? message.toolCallId : undefined;
|
|
30
|
+
if (id === undefined || !pending.delete(id)) {
|
|
31
|
+
throw new Error(`${label} contains an orphan tool result${id ? ` ${id}` : ''}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (pending.size > 0) {
|
|
35
|
+
throw new Error(`${label} contains ${pending.size} tool call(s) without results`);
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createCompactionTransaction({ source, turnId, tokensBefore, messageCountBefore }) {
|
|
41
|
+
const transaction = {
|
|
42
|
+
id: randomUUID(),
|
|
43
|
+
source,
|
|
44
|
+
turnId: turnId ?? null,
|
|
45
|
+
tokensBefore: positiveInteger(tokensBefore, 'tokensBefore'),
|
|
46
|
+
messageCountBefore: positiveInteger(messageCountBefore, 'messageCountBefore'),
|
|
47
|
+
state: 'started',
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
transaction,
|
|
51
|
+
record: {
|
|
52
|
+
type: 'compaction/start',
|
|
53
|
+
compaction_id: transaction.id,
|
|
54
|
+
source: transaction.source,
|
|
55
|
+
turn_id: transaction.turnId,
|
|
56
|
+
size_before: transaction.tokensBefore,
|
|
57
|
+
message_count_before: transaction.messageCountBefore,
|
|
58
|
+
unit: 'tokens',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function summarizeCompactionTransaction(transaction, input) {
|
|
64
|
+
if (transaction?.state !== 'started') {
|
|
65
|
+
throw new Error('compaction/summary requires exactly one open compaction/start');
|
|
66
|
+
}
|
|
67
|
+
if (typeof input.summary !== 'string' || input.summary.trim().length === 0) {
|
|
68
|
+
throw new Error('compaction/summary requires non-empty model-visible summary text');
|
|
69
|
+
}
|
|
70
|
+
assertBalancedToolPairs(input.historyBefore, 'history before compaction');
|
|
71
|
+
assertBalancedToolPairs(input.historyAfter, 'history after compaction');
|
|
72
|
+
transaction.state = 'summarized';
|
|
73
|
+
transaction.tokensAfter = positiveInteger(input.tokensAfter, 'tokensAfter');
|
|
74
|
+
transaction.messageCountAfter = positiveInteger(input.messageCountAfter, 'messageCountAfter');
|
|
75
|
+
return {
|
|
76
|
+
type: 'compaction/summary',
|
|
77
|
+
compaction_id: transaction.id,
|
|
78
|
+
source: transaction.source,
|
|
79
|
+
turn_id: transaction.turnId,
|
|
80
|
+
summary: input.summary,
|
|
81
|
+
shadowed_token_count: transaction.tokensBefore,
|
|
82
|
+
size_before: transaction.tokensBefore,
|
|
83
|
+
size_after: transaction.tokensAfter,
|
|
84
|
+
message_count_before: transaction.messageCountBefore,
|
|
85
|
+
message_count_after: transaction.messageCountAfter,
|
|
86
|
+
tool_pairing_balanced_before: true,
|
|
87
|
+
tool_pairing_balanced_after: true,
|
|
88
|
+
unit: 'tokens',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function endCompactionTransaction(transaction, { status, error } = {}) {
|
|
93
|
+
if (transaction?.state === 'ended') return undefined;
|
|
94
|
+
if (transaction?.state !== 'started' && transaction?.state !== 'summarized') {
|
|
95
|
+
throw new Error('compaction/end requires an open compaction/start');
|
|
96
|
+
}
|
|
97
|
+
const normalizedStatus = status ?? 'success';
|
|
98
|
+
if (normalizedStatus === 'success' && transaction.state !== 'summarized') {
|
|
99
|
+
throw new Error('successful compaction/end requires one compaction/summary');
|
|
100
|
+
}
|
|
101
|
+
if (!['success', 'cancelled', 'failed'].includes(normalizedStatus)) {
|
|
102
|
+
throw new Error(`unsupported compaction status ${normalizedStatus}`);
|
|
103
|
+
}
|
|
104
|
+
transaction.state = 'ended';
|
|
105
|
+
return {
|
|
106
|
+
type: 'compaction/end',
|
|
107
|
+
compaction_id: transaction.id,
|
|
108
|
+
source: transaction.source,
|
|
109
|
+
turn_id: transaction.turnId,
|
|
110
|
+
status: normalizedStatus,
|
|
111
|
+
...(transaction.tokensAfter === undefined ? {} : { size_after: transaction.tokensAfter }),
|
|
112
|
+
...(typeof error === 'string' && error.length > 0 ? { error } : {}),
|
|
113
|
+
unit: 'tokens',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
assertBalancedToolPairs,
|
|
119
|
+
createCompactionTransaction,
|
|
120
|
+
endCompactionTransaction,
|
|
121
|
+
summarizeCompactionTransaction,
|
|
122
|
+
};
|
|
@@ -3,12 +3,9 @@
|
|
|
3
3
|
const DEFAULT_COMPACTION_TRIGGER_MAX_TOKENS = 256_000;
|
|
4
4
|
const DEFAULT_COMPACTION_BLOCK_MAX_TOKENS = 288_000;
|
|
5
5
|
|
|
6
|
-
function capCompactionThreshold(threshold,
|
|
6
|
+
function capCompactionThreshold(threshold, _phase) {
|
|
7
7
|
if (threshold === undefined) return undefined;
|
|
8
|
-
|
|
9
|
-
? DEFAULT_COMPACTION_BLOCK_MAX_TOKENS
|
|
10
|
-
: DEFAULT_COMPACTION_TRIGGER_MAX_TOKENS;
|
|
11
|
-
return Math.min(threshold, maximum);
|
|
8
|
+
return threshold;
|
|
12
9
|
}
|
|
13
10
|
|
|
14
11
|
module.exports = {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CONTEXT_PRESSURE_RATIO = 0.75;
|
|
4
|
+
|
|
5
|
+
function hasContextPressure(options = {}) {
|
|
6
|
+
if (typeof options.underPressure === 'boolean') return options.underPressure;
|
|
7
|
+
const overridePercent = Number((options.env ?? process.env).BLUN_CONTEXT_PRESSURE_PERCENT);
|
|
8
|
+
const pressureRatio = Number.isFinite(overridePercent)
|
|
9
|
+
&& overridePercent > 0
|
|
10
|
+
&& overridePercent <= 100
|
|
11
|
+
? overridePercent / 100
|
|
12
|
+
: DEFAULT_CONTEXT_PRESSURE_RATIO;
|
|
13
|
+
return !Number.isFinite(options.contextUsageRatio)
|
|
14
|
+
|| options.contextUsageRatio >= pressureRatio;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
DEFAULT_CONTEXT_PRESSURE_RATIO,
|
|
19
|
+
hasContextPressure,
|
|
20
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_CHARS = 16_384;
|
|
6
|
+
|
|
7
|
+
function eventStepId(event, activeStepId) {
|
|
8
|
+
if (typeof event?.uuid === 'string') return event.uuid;
|
|
9
|
+
if (typeof event?.stepUuid === 'string') return event.stepUuid;
|
|
10
|
+
return activeStepId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readCronFinalMessage(wirePath, stepId, maxChars = DEFAULT_MAX_CHARS) {
|
|
14
|
+
if (typeof stepId !== 'string' || stepId.length === 0) return null;
|
|
15
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 1) throw new TypeError('maxChars must be positive');
|
|
16
|
+
let text = '';
|
|
17
|
+
let activeStepId = null;
|
|
18
|
+
const lines = fs.readFileSync(wirePath, 'utf8').split(/\r?\n/u);
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
if (line.length === 0) continue;
|
|
21
|
+
let record;
|
|
22
|
+
try {
|
|
23
|
+
record = JSON.parse(line);
|
|
24
|
+
} catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (record?.type !== 'context.append_loop_event') continue;
|
|
28
|
+
const event = record.event;
|
|
29
|
+
if (event?.type === 'step.begin') {
|
|
30
|
+
activeStepId = event.uuid;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (event?.type === 'step.end') {
|
|
34
|
+
if (eventStepId(event, activeStepId) === stepId) activeStepId = null;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (event?.type !== 'content.part' || eventStepId(event, activeStepId) !== stepId) continue;
|
|
38
|
+
if (event.part?.type === 'text' && typeof event.part.text === 'string') text += event.part.text;
|
|
39
|
+
}
|
|
40
|
+
if (text.length === 0) return null;
|
|
41
|
+
if (text.length <= maxChars) return {text, truncated: false, chars: text.length};
|
|
42
|
+
return {text: `${text.slice(0, maxChars)}\n...[truncated]`, truncated: true, chars: text.length};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {DEFAULT_MAX_CHARS, readCronFinalMessage};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const {
|
|
7
|
+
isValidCronRunReceipt,
|
|
8
|
+
markCronJobDeleted,
|
|
9
|
+
transitionCronRunReceipt,
|
|
10
|
+
} = require('./scoped-cron-run-policy.cjs');
|
|
11
|
+
|
|
12
|
+
const RUN_ID_RE = /^[0-9a-f]{16}$/u;
|
|
13
|
+
|
|
14
|
+
function atomicWriteJson(filePath, value) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filePath), {recursive: true, mode: 0o700});
|
|
16
|
+
const tempPath = `${filePath}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
|
17
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(value)}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'});
|
|
18
|
+
fs.renameSync(tempPath, filePath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createCronRunStore(sessionDir, options = {}) {
|
|
22
|
+
const rootDir = path.join(sessionDir, 'cron-runs');
|
|
23
|
+
const now = options.now || (() => Date.now());
|
|
24
|
+
const runs = new Map();
|
|
25
|
+
|
|
26
|
+
function filePath(runId) {
|
|
27
|
+
if (!RUN_ID_RE.test(runId)) throw new TypeError('Invalid cron run id');
|
|
28
|
+
return path.join(rootDir, `${runId}.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function persist(receipt) {
|
|
32
|
+
atomicWriteJson(filePath(receipt.runId), receipt);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function create(receipt) {
|
|
36
|
+
if (!isValidCronRunReceipt(receipt) || receipt.status !== 'buffered') {
|
|
37
|
+
throw new TypeError('Invalid new cron run receipt');
|
|
38
|
+
}
|
|
39
|
+
if (runs.has(receipt.runId)) throw new Error(`Cron run already exists: ${receipt.runId}`);
|
|
40
|
+
runs.set(receipt.runId, receipt);
|
|
41
|
+
persist(receipt);
|
|
42
|
+
return receipt;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function replace(receipt) {
|
|
46
|
+
if (!isValidCronRunReceipt(receipt)) throw new TypeError('Invalid cron run receipt');
|
|
47
|
+
if (!runs.has(receipt.runId)) throw new Error(`Unknown cron run: ${receipt.runId}`);
|
|
48
|
+
runs.set(receipt.runId, receipt);
|
|
49
|
+
persist(receipt);
|
|
50
|
+
return receipt;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function transition(runId, status, details) {
|
|
54
|
+
const current = runs.get(runId);
|
|
55
|
+
if (current === undefined) throw new Error(`Unknown cron run: ${runId}`);
|
|
56
|
+
return replace(transitionCronRunReceipt(current, status, details));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function markJobDeletedById(jobId) {
|
|
60
|
+
const changed = [];
|
|
61
|
+
for (const receipt of runs.values()) {
|
|
62
|
+
if (receipt.jobId !== jobId || receipt.jobDeleted === true) continue;
|
|
63
|
+
changed.push(replace(markCronJobDeleted(receipt)));
|
|
64
|
+
}
|
|
65
|
+
return changed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function load() {
|
|
69
|
+
runs.clear();
|
|
70
|
+
if (!fs.existsSync(rootDir)) return {loaded: 0, lost: 0, lostRunIds: []};
|
|
71
|
+
let loaded = 0;
|
|
72
|
+
let lost = 0;
|
|
73
|
+
const lostRunIds = [];
|
|
74
|
+
for (const entry of fs.readdirSync(rootDir, {withFileTypes: true})) {
|
|
75
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
76
|
+
const runId = entry.name.slice(0, -5);
|
|
77
|
+
if (!RUN_ID_RE.test(runId)) continue;
|
|
78
|
+
try {
|
|
79
|
+
const receipt = JSON.parse(fs.readFileSync(path.join(rootDir, entry.name), 'utf8'));
|
|
80
|
+
if (!isValidCronRunReceipt(receipt) || receipt.runId !== runId) continue;
|
|
81
|
+
let restored = receipt;
|
|
82
|
+
if (receipt.status === 'buffered' || receipt.status === 'running') {
|
|
83
|
+
restored = transitionCronRunReceipt(receipt, 'lost', {
|
|
84
|
+
endedAt: new Date(now()).toISOString(),
|
|
85
|
+
});
|
|
86
|
+
persist(restored);
|
|
87
|
+
lost += 1;
|
|
88
|
+
lostRunIds.push(runId);
|
|
89
|
+
}
|
|
90
|
+
runs.set(runId, restored);
|
|
91
|
+
loaded += 1;
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
return {loaded, lost, lostRunIds};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
create,
|
|
99
|
+
get: (runId) => runs.get(runId),
|
|
100
|
+
list: () => [...runs.values()],
|
|
101
|
+
load,
|
|
102
|
+
markJobDeletedById,
|
|
103
|
+
replace,
|
|
104
|
+
transition,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createMemoryCronRunStore() {
|
|
109
|
+
const runs = new Map();
|
|
110
|
+
function replace(receipt) {
|
|
111
|
+
if (!isValidCronRunReceipt(receipt)) throw new TypeError('Invalid cron run receipt');
|
|
112
|
+
if (!runs.has(receipt.runId)) throw new Error(`Unknown cron run: ${receipt.runId}`);
|
|
113
|
+
runs.set(receipt.runId, receipt);
|
|
114
|
+
return receipt;
|
|
115
|
+
}
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
create(receipt) {
|
|
118
|
+
if (!isValidCronRunReceipt(receipt) || receipt.status !== 'buffered') {
|
|
119
|
+
throw new TypeError('Invalid new cron run receipt');
|
|
120
|
+
}
|
|
121
|
+
if (runs.has(receipt.runId)) throw new Error(`Cron run already exists: ${receipt.runId}`);
|
|
122
|
+
runs.set(receipt.runId, receipt);
|
|
123
|
+
return receipt;
|
|
124
|
+
},
|
|
125
|
+
get: (runId) => runs.get(runId),
|
|
126
|
+
list: () => [...runs.values()],
|
|
127
|
+
load: () => ({loaded: 0, lost: 0, lostRunIds: []}),
|
|
128
|
+
markJobDeletedById(jobId) {
|
|
129
|
+
const changed = [];
|
|
130
|
+
for (const receipt of runs.values()) {
|
|
131
|
+
if (receipt.jobId !== jobId || receipt.jobDeleted === true) continue;
|
|
132
|
+
changed.push(replace(markCronJobDeleted(receipt)));
|
|
133
|
+
}
|
|
134
|
+
return changed;
|
|
135
|
+
},
|
|
136
|
+
replace,
|
|
137
|
+
transition(runId, status, details) {
|
|
138
|
+
const current = runs.get(runId);
|
|
139
|
+
if (current === undefined) throw new Error(`Unknown cron run: ${runId}`);
|
|
140
|
+
return replace(transitionCronRunReceipt(current, status, details));
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {createCronRunStore, createMemoryCronRunStore};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MODEL_ALIAS = 'blun/king';
|
|
4
|
+
const DEFAULT_MAX_OUTPUT_SIZE = 128000;
|
|
5
|
+
|
|
6
|
+
function applyDefaultModelOutputBudget(config) {
|
|
7
|
+
const models = config?.models;
|
|
8
|
+
const model = models?.[DEFAULT_MODEL_ALIAS];
|
|
9
|
+
|
|
10
|
+
if (!model || model.maxOutputSize !== undefined) return config;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
...config,
|
|
14
|
+
models: {
|
|
15
|
+
...models,
|
|
16
|
+
[DEFAULT_MODEL_ALIAS]: {
|
|
17
|
+
...model,
|
|
18
|
+
maxOutputSize: DEFAULT_MAX_OUTPUT_SIZE,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
DEFAULT_MAX_OUTPUT_SIZE,
|
|
26
|
+
DEFAULT_MODEL_ALIAS,
|
|
27
|
+
applyDefaultModelOutputBudget,
|
|
28
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const INTERRUPTED_TOOL_REMINDER = [
|
|
6
|
+
"The previous process ended during this agent's work.",
|
|
7
|
+
'An in-flight tool call may have completed without its result being observed.',
|
|
8
|
+
'Inspect current state before repeating any side effect.',
|
|
9
|
+
].join(' ');
|
|
10
|
+
|
|
11
|
+
const TASK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/u;
|
|
12
|
+
const DIGEST_PATTERN = /^[0-9a-f]{64}$/u;
|
|
13
|
+
const FAILURE_CATEGORIES = new Set(['validation', 'conflict', 'launcher', 'persistence', 'unknown']);
|
|
14
|
+
const INTENT_KEYS = new Set([
|
|
15
|
+
'version',
|
|
16
|
+
'status',
|
|
17
|
+
'sourceTaskId',
|
|
18
|
+
'replacementTaskId',
|
|
19
|
+
'agentId',
|
|
20
|
+
'resumeRootTaskId',
|
|
21
|
+
'requestDigest',
|
|
22
|
+
'createdAt',
|
|
23
|
+
'startedAt',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function contractError(message, category = 'validation') {
|
|
27
|
+
const error = new Error(message);
|
|
28
|
+
error.code = 'ERR_DURABLE_TASK_RESUME';
|
|
29
|
+
error.category = FAILURE_CATEGORIES.has(category) ? category : 'unknown';
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertTaskId(value, field = 'task ID') {
|
|
34
|
+
if (typeof value !== 'string' || !TASK_ID_PATTERN.test(value)) {
|
|
35
|
+
throw contractError(`${field} is invalid`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertText(value, field, maxBytes) {
|
|
41
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
42
|
+
throw contractError(`${field} is required`);
|
|
43
|
+
}
|
|
44
|
+
if (Buffer.byteLength(value, 'utf8') > maxBytes) {
|
|
45
|
+
throw contractError(`${field} is too large`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requestDigest(prompt, description) {
|
|
51
|
+
assertText(prompt, 'prompt', 32 * 1024);
|
|
52
|
+
assertText(description, 'description', 1024);
|
|
53
|
+
return crypto.createHash('sha256')
|
|
54
|
+
.update(JSON.stringify({ prompt, description }))
|
|
55
|
+
.digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isResumedSuccessor(task) {
|
|
59
|
+
return task.status === 'completed'
|
|
60
|
+
&& task.resumed === true
|
|
61
|
+
&& typeof task.replacesTaskId === 'string'
|
|
62
|
+
&& typeof task.resumeRootTaskId === 'string';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateResumeSource(task) {
|
|
66
|
+
if (!task
|
|
67
|
+
|| task.kind !== 'agent'
|
|
68
|
+
|| typeof task.agentId !== 'string'
|
|
69
|
+
|| task.agentId.length === 0
|
|
70
|
+
|| (task.status !== 'lost' && !isResumedSuccessor(task))) {
|
|
71
|
+
throw contractError('Task is not a recoverable agent task. Only a lost agent task or a completed TaskResume successor can be resumed.');
|
|
72
|
+
}
|
|
73
|
+
assertTaskId(task.taskId);
|
|
74
|
+
return task;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createPreparedIntent({ source, replacementTaskId, digest, now }) {
|
|
78
|
+
validateResumeSource(source);
|
|
79
|
+
assertTaskId(replacementTaskId, 'replacement task ID');
|
|
80
|
+
if (replacementTaskId === source.taskId) {
|
|
81
|
+
throw contractError('Replacement task ID must differ from source task ID', 'conflict');
|
|
82
|
+
}
|
|
83
|
+
if (typeof digest !== 'string' || !DIGEST_PATTERN.test(digest)) {
|
|
84
|
+
throw contractError('request digest is invalid');
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isFinite(now)) throw contractError('intent timestamp is invalid');
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
status: 'prepared',
|
|
90
|
+
sourceTaskId: source.taskId,
|
|
91
|
+
replacementTaskId,
|
|
92
|
+
agentId: source.agentId,
|
|
93
|
+
resumeRootTaskId: source.resumeRootTaskId || source.taskId,
|
|
94
|
+
requestDigest: digest,
|
|
95
|
+
createdAt: now,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isResumeIntent(value) {
|
|
100
|
+
if (!value || typeof value !== 'object') return false;
|
|
101
|
+
if (Object.keys(value).some((key) => !INTENT_KEYS.has(key))) return false;
|
|
102
|
+
if (value.version !== 1 || (value.status !== 'prepared' && value.status !== 'started')) return false;
|
|
103
|
+
if (!TASK_ID_PATTERN.test(value.sourceTaskId) || !TASK_ID_PATTERN.test(value.replacementTaskId)) return false;
|
|
104
|
+
if (typeof value.agentId !== 'string' || value.agentId.length === 0) return false;
|
|
105
|
+
if (!TASK_ID_PATTERN.test(value.resumeRootTaskId) || !DIGEST_PATTERN.test(value.requestDigest)) return false;
|
|
106
|
+
if (!Number.isFinite(value.createdAt)) return false;
|
|
107
|
+
if (value.status === 'started' && !Number.isFinite(value.startedAt)) return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function decorateTaskInfo(task, intent) {
|
|
112
|
+
if (!task || !intent || intent.sourceTaskId !== task.taskId) return task;
|
|
113
|
+
return { ...task, replacementTaskId: intent.replacementTaskId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function failureCategory(error) {
|
|
117
|
+
return FAILURE_CATEGORIES.has(error && error.category) ? error.category : 'launcher';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
INTERRUPTED_TOOL_REMINDER,
|
|
122
|
+
TASK_ID_PATTERN,
|
|
123
|
+
contractError,
|
|
124
|
+
createPreparedIntent,
|
|
125
|
+
decorateTaskInfo,
|
|
126
|
+
failureCategory,
|
|
127
|
+
isResumeIntent,
|
|
128
|
+
requestDigest,
|
|
129
|
+
validateResumeSource,
|
|
130
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
INTERRUPTED_TOOL_REMINDER,
|
|
5
|
+
contractError,
|
|
6
|
+
createPreparedIntent,
|
|
7
|
+
failureCategory,
|
|
8
|
+
requestDigest,
|
|
9
|
+
validateResumeSource,
|
|
10
|
+
} = require('./durable-task-resume-policy.cjs');
|
|
11
|
+
|
|
12
|
+
class DurableTaskResumeRuntime {
|
|
13
|
+
constructor(adapters) {
|
|
14
|
+
this.adapters = adapters;
|
|
15
|
+
this.inFlightSources = new Set();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async resolveIntent(source, digest, now) {
|
|
19
|
+
const existing = this.adapters.getIntent(source.taskId);
|
|
20
|
+
if (existing) {
|
|
21
|
+
const successor = this.adapters.getTask(existing.replacementTaskId);
|
|
22
|
+
if (successor || existing.status === 'started') {
|
|
23
|
+
throw contractError(`Task ${source.taskId} already has successor ${existing.replacementTaskId}`, 'conflict');
|
|
24
|
+
}
|
|
25
|
+
if (existing.requestDigest !== digest) {
|
|
26
|
+
throw contractError('Resume request differs from prepared intent', 'conflict');
|
|
27
|
+
}
|
|
28
|
+
return existing;
|
|
29
|
+
}
|
|
30
|
+
const replacementTaskId = this.adapters.reserveTaskId('agent');
|
|
31
|
+
const intent = createPreparedIntent({ source, replacementTaskId, digest, now });
|
|
32
|
+
await this.adapters.writeIntent(intent);
|
|
33
|
+
this.adapters.emitEvent({
|
|
34
|
+
type: 'background.task.resume_requested',
|
|
35
|
+
sourceTaskId: intent.sourceTaskId,
|
|
36
|
+
replacementTaskId: intent.replacementTaskId,
|
|
37
|
+
agentId: intent.agentId,
|
|
38
|
+
});
|
|
39
|
+
return intent;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async resume(input) {
|
|
43
|
+
const runSignal = input.controller ? input.controller.signal : input.signal;
|
|
44
|
+
runSignal.throwIfAborted();
|
|
45
|
+
if (this.inFlightSources.has(input.taskId)) {
|
|
46
|
+
throw contractError(`Task ${input.taskId} already has a resume request in flight`, 'conflict');
|
|
47
|
+
}
|
|
48
|
+
this.inFlightSources.add(input.taskId);
|
|
49
|
+
try {
|
|
50
|
+
const source = validateResumeSource(this.adapters.getTask(input.taskId));
|
|
51
|
+
const digest = requestDigest(input.prompt, input.description);
|
|
52
|
+
const now = this.adapters.now();
|
|
53
|
+
let intent;
|
|
54
|
+
try {
|
|
55
|
+
intent = await this.resolveIntent(source, digest, now);
|
|
56
|
+
runSignal.throwIfAborted();
|
|
57
|
+
const handle = await this.adapters.resumeAgent(intent.agentId, {
|
|
58
|
+
parentToolCallId: input.parentToolCallId,
|
|
59
|
+
prompt: input.prompt,
|
|
60
|
+
description: input.description,
|
|
61
|
+
runInBackground: true,
|
|
62
|
+
responseFormat: undefined,
|
|
63
|
+
toolScope: input.toolScope,
|
|
64
|
+
signal: runSignal,
|
|
65
|
+
systemReminder: INTERRUPTED_TOOL_REMINDER,
|
|
66
|
+
});
|
|
67
|
+
if (!handle || handle.agentId !== intent.agentId) {
|
|
68
|
+
throw contractError('Resume launcher changed the preserved agent identity', 'launcher');
|
|
69
|
+
}
|
|
70
|
+
const metadata = {
|
|
71
|
+
timeoutMinutes: input.timeoutMinutes,
|
|
72
|
+
parentToolCallId: input.parentToolCallId,
|
|
73
|
+
controller: input.controller,
|
|
74
|
+
replacesTaskId: intent.sourceTaskId,
|
|
75
|
+
resumeRootTaskId: intent.resumeRootTaskId,
|
|
76
|
+
};
|
|
77
|
+
const task = this.adapters.createTask(handle, input.description, metadata);
|
|
78
|
+
const registeredTaskId = this.adapters.registerTask(task, {
|
|
79
|
+
detached: true,
|
|
80
|
+
taskId: intent.replacementTaskId,
|
|
81
|
+
});
|
|
82
|
+
if (registeredTaskId !== intent.replacementTaskId) {
|
|
83
|
+
throw contractError('Background manager did not honor the reserved replacement task ID', 'launcher');
|
|
84
|
+
}
|
|
85
|
+
await this.adapters.awaitTaskPersisted(registeredTaskId);
|
|
86
|
+
await this.adapters.markIntentStarted(intent.sourceTaskId, now);
|
|
87
|
+
this.adapters.emitEvent({
|
|
88
|
+
type: 'background.task.resumed',
|
|
89
|
+
sourceTaskId: intent.sourceTaskId,
|
|
90
|
+
replacementTaskId: intent.replacementTaskId,
|
|
91
|
+
agentId: intent.agentId,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
taskId: registeredTaskId,
|
|
95
|
+
agentId: intent.agentId,
|
|
96
|
+
replacesTaskId: intent.sourceTaskId,
|
|
97
|
+
resumeRootTaskId: intent.resumeRootTaskId,
|
|
98
|
+
status: 'running',
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (intent) {
|
|
102
|
+
this.adapters.emitEvent({
|
|
103
|
+
type: 'background.task.resume_failed',
|
|
104
|
+
sourceTaskId: intent.sourceTaskId,
|
|
105
|
+
replacementTaskId: intent.replacementTaskId,
|
|
106
|
+
category: failureCategory(error),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
this.inFlightSources.delete(input.taskId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { DurableTaskResumeRuntime };
|