blun-king-cli 9.1.527 → 9.1.550
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 -834
- package/README.md +41 -799
- package/bin/agent-resume-snapshot.cjs +31 -0
- package/bin/assistant-message-offload-policy.cjs +23 -2
- package/bin/codebase-search-runtime.cjs +23 -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/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/empty-response-retry-policy.cjs +29 -0
- package/bin/history-offload-pressure-policy.cjs +33 -0
- package/bin/html-to-research-markdown.cjs +146 -0
- package/bin/programmatic-context-isolation.cjs +25 -0
- package/bin/programmatic-tool-runtime.mjs +627 -0
- package/bin/read-continuation-policy.cjs +36 -5
- package/bin/scoped-cron-run-policy.cjs +358 -0
- package/bin/skill-activation-performance-policy.cjs +9 -0
- package/bin/startup-preferences.cjs +3 -3
- package/bin/structured-agent-swarm-output.cjs +325 -0
- package/bin/structured-subagent-output.cjs +252 -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/telegram-direct-focus-policy.cjs +25 -1
- package/bin/todo-list-turn-policy.cjs +111 -1
- package/bin/tool-result-offload-policy.cjs +43 -1
- package/bin/turn-thinking-policy.cjs +6 -15
- package/bin/turn-tool-performance-policy.cjs +5 -4
- package/bin/update-notice.js +14 -18
- package/bin/user-message-offload-policy.cjs +12 -1
- package/blun.mjs +1406 -265
- package/codebase-index/README.md +82 -0
- package/codebase-index/codebase_index.py +469 -0
- package/package.json +24 -38
- package/telegram-plugin/bin/telegram-typing-keepalive.cjs +89 -0
- package/telegram-plugin/dist/bridge.mjs +8 -1
- package/CHANGELOG.md +0 -260
- 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/package-regression-policy.cjs +0 -77
- package/release-planned-removals.json +0 -15
- package/scripts/check-active-profile-plugin-startup.js +0 -36
- 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-copy-command-regression.js +0 -74
- package/scripts/check-historical-tool-result-preview-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-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 -100
- 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-telegram-bridge-watchdog.js +0 -60
- package/scripts/check-telegram-loop-exactly-once-regression.js +0 -71
- package/scripts/check-todo-loop-regression.js +0 -78
- package/scripts/check-todo-recovery-catalog-regression.js +0 -50
- /package/{scripts → bin}/fix-node-pty-perms.js +0 -0
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const AGENT_RESUME_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
4
|
+
const MAX_TODO_PROGRESS_EVIDENCE = 8;
|
|
5
|
+
const MAX_TODO_PROGRESS_EVIDENCE_CHARS = 200;
|
|
4
6
|
|
|
5
7
|
function copyJsonObject(value, fallback) {
|
|
6
8
|
if (value === undefined) return fallback;
|
|
7
9
|
return JSON.parse(JSON.stringify(value));
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
function normalizeTodoProgressState(value) {
|
|
13
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
14
|
+
const nonNegativeInteger = (candidate, maximum) => Number.isSafeInteger(candidate)
|
|
15
|
+
? Math.max(0, Math.min(candidate, maximum))
|
|
16
|
+
: 0;
|
|
17
|
+
const evidenceRevision = nonNegativeInteger(value.evidenceRevision, Number.MAX_SAFE_INTEGER);
|
|
18
|
+
return {
|
|
19
|
+
workCallsSinceRefresh: nonNegativeInteger(value.workCallsSinceRefresh, 64),
|
|
20
|
+
refreshRequired: value.refreshRequired === true,
|
|
21
|
+
unchangedRefreshStreak: nonNegativeInteger(value.unchangedRefreshStreak, 3),
|
|
22
|
+
recentEvidence: Array.isArray(value.recentEvidence)
|
|
23
|
+
? value.recentEvidence
|
|
24
|
+
.filter((entry) => typeof entry === 'string')
|
|
25
|
+
.slice(-MAX_TODO_PROGRESS_EVIDENCE)
|
|
26
|
+
.map((entry) => entry.slice(0, MAX_TODO_PROGRESS_EVIDENCE_CHARS))
|
|
27
|
+
: [],
|
|
28
|
+
evidenceRevision,
|
|
29
|
+
lastMaintenanceEvidenceRevision: Math.min(
|
|
30
|
+
nonNegativeInteger(value.lastMaintenanceEvidenceRevision, Number.MAX_SAFE_INTEGER),
|
|
31
|
+
evidenceRevision,
|
|
32
|
+
),
|
|
33
|
+
allVisibleWorkCompleted: value.allVisibleWorkCompleted === true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
function captureMessageTimes(agent) {
|
|
11
38
|
const replayTimes = new Map();
|
|
12
39
|
for (const record of agent.replayBuilder.records) {
|
|
@@ -63,6 +90,7 @@ function createAgentResumeSnapshot(agent) {
|
|
|
63
90
|
allowedTools: agent.tools.allowedTools,
|
|
64
91
|
excludedTools: [...agent.tools.excludedTools],
|
|
65
92
|
},
|
|
93
|
+
todoProgress: normalizeTodoProgressState(agent.goalTodoPolicyState) ?? null,
|
|
66
94
|
goal: agent.goal.state ?? null,
|
|
67
95
|
turn: {
|
|
68
96
|
turnId: agent.turn.turnId,
|
|
@@ -175,6 +203,8 @@ function restoreAgentResumeSnapshot(agent, snapshot) {
|
|
|
175
203
|
: [];
|
|
176
204
|
if (agent.config.hasProvider) agent.tools.initializeBuiltinTools();
|
|
177
205
|
|
|
206
|
+
agent.goalTodoPolicyState = normalizeTodoProgressState(snapshot.todoProgress);
|
|
207
|
+
|
|
178
208
|
agent.goal.state = snapshot.goal === null
|
|
179
209
|
? undefined
|
|
180
210
|
: copyJsonObject(snapshot.goal, undefined);
|
|
@@ -206,5 +236,6 @@ function restoreAgentResumeSnapshot(agent, snapshot) {
|
|
|
206
236
|
module.exports = {
|
|
207
237
|
AGENT_RESUME_SNAPSHOT_SCHEMA_VERSION,
|
|
208
238
|
createAgentResumeSnapshot,
|
|
239
|
+
normalizeTodoProgressState,
|
|
209
240
|
restoreAgentResumeSnapshot,
|
|
210
241
|
};
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const {
|
|
4
|
+
hasHistoricalOffloadPressure,
|
|
5
|
+
historyTextChars,
|
|
6
|
+
} = require('./history-offload-pressure-policy.cjs');
|
|
7
|
+
const { hasContextPressure } = require('./context-pressure-policy.cjs');
|
|
8
|
+
|
|
3
9
|
const ASSISTANT_MESSAGE_MAX_CHARS = 4_000;
|
|
4
10
|
const ASSISTANT_MESSAGE_KEEP_RECENT_MESSAGES = 20;
|
|
5
11
|
const ASSISTANT_MESSAGE_PREVIEW_LINES = 5;
|
|
@@ -34,6 +40,7 @@ function isSyntheticToolArguments(value) {
|
|
|
34
40
|
function shouldOffloadHistoricalAssistantMessage(options = {}) {
|
|
35
41
|
const historyIndex = Number(options.historyIndex);
|
|
36
42
|
const historyLength = Number(options.historyLength);
|
|
43
|
+
const historyChars = Number(options.historyChars);
|
|
37
44
|
const textChars = Number(options.textChars);
|
|
38
45
|
if (
|
|
39
46
|
!Number.isSafeInteger(historyIndex)
|
|
@@ -44,7 +51,10 @@ function shouldOffloadHistoricalAssistantMessage(options = {}) {
|
|
|
44
51
|
|| historyIndex >= historyLength
|
|
45
52
|
) return false;
|
|
46
53
|
const cutoff = Math.max(0, historyLength - ASSISTANT_MESSAGE_KEEP_RECENT_MESSAGES);
|
|
47
|
-
return
|
|
54
|
+
return hasContextPressure(options)
|
|
55
|
+
&& hasHistoricalOffloadPressure({ historyLength, historyChars })
|
|
56
|
+
&& historyIndex < cutoff
|
|
57
|
+
&& textChars > ASSISTANT_MESSAGE_MAX_CHARS;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function createAssistantMessagePreview(text) {
|
|
@@ -68,6 +78,7 @@ function createAssistantMessagePreview(text) {
|
|
|
68
78
|
function shouldCompactHistoricalAssistantToolNarration(options = {}) {
|
|
69
79
|
const historyIndex = Number(options.historyIndex);
|
|
70
80
|
const historyLength = Number(options.historyLength);
|
|
81
|
+
const historyChars = Number(options.historyChars);
|
|
71
82
|
const textChars = Number(options.textChars);
|
|
72
83
|
const toolCallCount = Number(options.toolCallCount);
|
|
73
84
|
const completedToolCallCount = Number(options.completedToolCallCount);
|
|
@@ -83,13 +94,18 @@ function shouldCompactHistoricalAssistantToolNarration(options = {}) {
|
|
|
83
94
|
|| toolCallCount <= 0
|
|
84
95
|
) return false;
|
|
85
96
|
const cutoff = Math.max(0, historyLength - ASSISTANT_MESSAGE_KEEP_RECENT_MESSAGES);
|
|
86
|
-
return
|
|
97
|
+
return hasHistoricalOffloadPressure({ historyLength, historyChars })
|
|
98
|
+
&& historyIndex < cutoff
|
|
87
99
|
&& textChars > ASSISTANT_TOOL_NARRATION_MIN_CHARS
|
|
88
100
|
&& completedToolCallCount === toolCallCount;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
function compactHistoricalPostTelegramReplyNarration(messages) {
|
|
92
104
|
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
105
|
+
if (!hasHistoricalOffloadPressure({
|
|
106
|
+
historyLength: messages.length,
|
|
107
|
+
historyChars: historyTextChars(messages),
|
|
108
|
+
})) return messages;
|
|
93
109
|
|
|
94
110
|
const toolNameByCallId = new Map(messages.flatMap((message) => (
|
|
95
111
|
message?.role === 'assistant' && Array.isArray(message.toolCalls)
|
|
@@ -175,6 +191,10 @@ function compactCompletedSubagentCall(call, completedIds) {
|
|
|
175
191
|
|
|
176
192
|
function compactHistoricalCompletedSubagentPrompts(messages) {
|
|
177
193
|
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
194
|
+
if (!hasHistoricalOffloadPressure({
|
|
195
|
+
historyLength: messages.length,
|
|
196
|
+
historyChars: historyTextChars(messages),
|
|
197
|
+
})) return messages;
|
|
178
198
|
|
|
179
199
|
const completedIds = completedSubagentResultIds(messages);
|
|
180
200
|
if (completedIds.size === 0) return messages;
|
|
@@ -256,6 +276,7 @@ module.exports = {
|
|
|
256
276
|
compactHistoricalCompletedSubagentPrompts,
|
|
257
277
|
compactHistoricalPostTelegramReplyNarration,
|
|
258
278
|
createAssistantMessagePreview,
|
|
279
|
+
historyTextChars,
|
|
259
280
|
isSyntheticToolArguments,
|
|
260
281
|
removeSyntheticToolArgumentFailures,
|
|
261
282
|
shouldCompactHistoricalAssistantToolNarration,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { existsSync } = require('node:fs');
|
|
4
|
+
const { fileURLToPath } = require('node:url');
|
|
5
|
+
const { isAbsolute } = require('node:path');
|
|
6
|
+
|
|
7
|
+
function resolveCodebaseIndexScript(moduleUrl, envPath) {
|
|
8
|
+
if (typeof moduleUrl !== 'string' || moduleUrl.length === 0) return '';
|
|
9
|
+
|
|
10
|
+
if (typeof envPath === 'string' && envPath.length > 0) {
|
|
11
|
+
if (!isAbsolute(envPath) || !existsSync(envPath)) return '';
|
|
12
|
+
return envPath;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const packaged = fileURLToPath(new URL('./codebase-index/codebase_index.py', moduleUrl));
|
|
17
|
+
return existsSync(packaged) ? packaged : '';
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { resolveCodebaseIndexScript };
|
|
@@ -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,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 };
|