blun-king-cli 9.1.536 → 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 -869
- package/README.md +41 -833
- package/bin/assistant-message-offload-policy.cjs +3 -1
- 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/html-to-research-markdown.cjs +146 -0
- package/bin/programmatic-tool-runtime.mjs +330 -4
- package/bin/read-continuation-policy.cjs +36 -5
- package/bin/scoped-cron-run-policy.cjs +358 -0
- package/bin/startup-preferences.cjs +3 -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-result-offload-policy.cjs +14 -1
- package/bin/update-notice.js +14 -18
- package/bin/user-message-offload-policy.cjs +3 -1
- package/blun.mjs +1089 -477
- 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-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/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
|
}
|
|
@@ -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 };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const {
|
|
6
|
+
TASK_ID_PATTERN,
|
|
7
|
+
contractError,
|
|
8
|
+
isResumeIntent,
|
|
9
|
+
} = require('./durable-task-resume-policy.cjs');
|
|
10
|
+
|
|
11
|
+
class DurableTaskResumeStore {
|
|
12
|
+
constructor(sessionDir) {
|
|
13
|
+
if (typeof sessionDir !== 'string' || sessionDir.length === 0) {
|
|
14
|
+
throw contractError('session directory is required', 'persistence');
|
|
15
|
+
}
|
|
16
|
+
this.directory = path.join(sessionDir, 'task-resume-intents');
|
|
17
|
+
this.intents = new Map();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fileFor(sourceTaskId) {
|
|
21
|
+
if (!TASK_ID_PATTERN.test(sourceTaskId)) throw contractError('source task ID is invalid');
|
|
22
|
+
return path.join(this.directory, `${sourceTaskId}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async load() {
|
|
26
|
+
this.intents.clear();
|
|
27
|
+
let names;
|
|
28
|
+
try {
|
|
29
|
+
names = await fs.readdir(this.directory);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error && error.code === 'ENOENT') return;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
if (!name.endsWith('.json')) continue;
|
|
36
|
+
const sourceTaskId = name.slice(0, -5);
|
|
37
|
+
if (!TASK_ID_PATTERN.test(sourceTaskId)) continue;
|
|
38
|
+
try {
|
|
39
|
+
const intent = JSON.parse(await fs.readFile(this.fileFor(sourceTaskId), 'utf8'));
|
|
40
|
+
if (isResumeIntent(intent) && intent.sourceTaskId === sourceTaskId) {
|
|
41
|
+
this.intents.set(sourceTaskId, intent);
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getBySourceTaskId(sourceTaskId) {
|
|
48
|
+
const intent = this.intents.get(sourceTaskId);
|
|
49
|
+
return intent === undefined ? undefined : { ...intent };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
list() {
|
|
53
|
+
return [...this.intents.values()].map((intent) => ({ ...intent }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
startupRecoveryPlan() {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async write(intent) {
|
|
61
|
+
if (!isResumeIntent(intent)) throw contractError('resume intent is invalid', 'persistence');
|
|
62
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
63
|
+
const target = this.fileFor(intent.sourceTaskId);
|
|
64
|
+
const temporary = `${target}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
65
|
+
try {
|
|
66
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
67
|
+
try {
|
|
68
|
+
await handle.writeFile(JSON.stringify(intent, null, 2), 'utf8');
|
|
69
|
+
await handle.sync();
|
|
70
|
+
} finally {
|
|
71
|
+
await handle.close();
|
|
72
|
+
}
|
|
73
|
+
await fs.rename(temporary, target);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
this.intents.set(intent.sourceTaskId, { ...intent });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async markStarted(sourceTaskId, startedAt) {
|
|
82
|
+
const intent = this.intents.get(sourceTaskId);
|
|
83
|
+
if (!intent) throw contractError('prepared resume intent not found', 'persistence');
|
|
84
|
+
await this.write({ ...intent, status: 'started', startedAt });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { DurableTaskResumeStore };
|