larkin 0.2.70
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/CONTRIBUTING.md +48 -0
- package/LICENSE +201 -0
- package/README.md +122 -0
- package/SECURITY.md +14 -0
- package/artifacts/release/THIRD_PARTY_NOTICES.txt +5488 -0
- package/dist/agent/agent-cli-capabilities.cjs +43 -0
- package/dist/agent/agent-cli-capabilities.mjs +35 -0
- package/dist/agent/agent-state-store.cjs +961 -0
- package/dist/agent/agent-state-store.mjs +923 -0
- package/dist/agent/agent-transport.cjs +201 -0
- package/dist/agent/agent-transport.mjs +194 -0
- package/dist/agent/config-cli-contract.cjs +19 -0
- package/dist/agent/config-cli-contract.mjs +16 -0
- package/dist/agent/context-prompt.cjs +159 -0
- package/dist/agent/context-prompt.mjs +155 -0
- package/dist/agent/freshness-gate.mjs +24 -0
- package/dist/agent/history-projection.cjs +72 -0
- package/dist/agent/history-projection.mjs +65 -0
- package/dist/agent/host-reminder-orchestrator.cjs +289 -0
- package/dist/agent/host-reminder-orchestrator.mjs +252 -0
- package/dist/agent/inbox-projection.cjs +92 -0
- package/dist/agent/inbox-projection.mjs +85 -0
- package/dist/agent/interaction-state-machine.cjs +710 -0
- package/dist/agent/interaction-state-machine.mjs +705 -0
- package/dist/agent/reminder-routes.cjs +192 -0
- package/dist/agent/reminder-routes.mjs +156 -0
- package/dist/agent/reminder-store.cjs +220 -0
- package/dist/agent/reminder-store.mjs +205 -0
- package/dist/agent/transport-business-context.cjs +486 -0
- package/dist/agent/transport-business-context.mjs +446 -0
- package/dist/agent/transport-shell.cjs +45 -0
- package/dist/agent/transport-shell.mjs +39 -0
- package/dist/app/agent-cli.mjs +596 -0
- package/dist/app/agent-config.mjs +633 -0
- package/dist/app/agent-lark-cli-workspace.cjs +95 -0
- package/dist/app/agent-lark-cli-workspace.mjs +85 -0
- package/dist/app/agent-readiness.cjs +42 -0
- package/dist/app/agent-readiness.mjs +38 -0
- package/dist/app/binary-entry.mjs +86 -0
- package/dist/app/cli.mjs +176 -0
- package/dist/app/dashboard.mjs +205 -0
- package/dist/app/internal-command.cjs +57 -0
- package/dist/app/internal-command.mjs +48 -0
- package/dist/app/lark-channel-secret.mjs +22 -0
- package/dist/app/lark-cli.mjs +887 -0
- package/dist/app/lark.mjs +45 -0
- package/dist/app/local-control.mjs +728 -0
- package/dist/app/official-lark-cli.cjs +138 -0
- package/dist/app/official-lark-cli.mjs +128 -0
- package/dist/app/pi-auth-cli.mjs +67 -0
- package/dist/app/run.mjs +364 -0
- package/dist/app/runtime-agent-config.mjs +416 -0
- package/dist/app/runtime-model-directory.mjs +78 -0
- package/dist/app/runtime-process.mjs +113 -0
- package/dist/app/session-cli.mjs +83 -0
- package/dist/app/setup-dashboard.mjs +26 -0
- package/dist/app/setup.mjs +166 -0
- package/dist/app/telemetry.mjs +83 -0
- package/dist/dashboard/dashboard-assets.mjs +21 -0
- package/dist/dashboard/dashboard-avatar.mjs +128 -0
- package/dist/dashboard/dashboard-config-controller.mjs +525 -0
- package/dist/dashboard/dashboard-lifecycle.cjs +97 -0
- package/dist/dashboard/dashboard-lifecycle.mjs +88 -0
- package/dist/dashboard/dashboard-template.mjs +29 -0
- package/dist/dashboard/dashboard-view-model.mjs +556 -0
- package/dist/dashboard/dashboard-workspace.mjs +104 -0
- package/dist/dashboard/web/assets/dashboard.css +3 -0
- package/dist/dashboard/web/assets/dashboard.js +49 -0
- package/dist/feishu/channel-transport.cjs +151 -0
- package/dist/feishu/channel-transport.mjs +148 -0
- package/dist/feishu/document-comment.cjs +120 -0
- package/dist/feishu/document-comment.mjs +109 -0
- package/dist/feishu/host-business-state.cjs +477 -0
- package/dist/feishu/host-business-state.mjs +462 -0
- package/dist/feishu/host-channel-business.cjs +161 -0
- package/dist/feishu/host-channel-business.mjs +157 -0
- package/dist/feishu/host-processing-eye.cjs +185 -0
- package/dist/feishu/host-processing-eye.mjs +181 -0
- package/dist/feishu/host-shell.cjs +1425 -0
- package/dist/feishu/host-shell.mjs +1385 -0
- package/dist/feishu/im-freshness-adapter.mjs +89 -0
- package/dist/feishu/interaction-orchestrator.cjs +279 -0
- package/dist/feishu/interaction-orchestrator.mjs +242 -0
- package/dist/feishu/lark-passthrough.mjs +70 -0
- package/dist/feishu/message-policy.cjs +111 -0
- package/dist/feishu/message-policy.mjs +98 -0
- package/dist/feishu/outbound-transport.cjs +221 -0
- package/dist/feishu/outbound-transport.mjs +215 -0
- package/dist/platform/build-info.cjs +25 -0
- package/dist/platform/build-info.mjs +18 -0
- package/dist/platform/callback-capability.cjs +301 -0
- package/dist/platform/callback-capability.mjs +252 -0
- package/dist/platform/check-bun-version.cjs +12 -0
- package/dist/platform/config.cjs +751 -0
- package/dist/platform/config.mjs +694 -0
- package/dist/platform/process-inspect.cjs +89 -0
- package/dist/platform/process-state.cjs +237 -0
- package/dist/platform/process-state.mjs +223 -0
- package/dist/platform/release-artifacts.mjs +61 -0
- package/dist/platform/root-layout.cjs +111 -0
- package/dist/platform/root-layout.mjs +72 -0
- package/dist/platform/telemetry-config.cjs +64 -0
- package/dist/platform/telemetry-config.mjs +56 -0
- package/dist/platform/telemetry-spool.cjs +661 -0
- package/dist/platform/telemetry-spool.mjs +653 -0
- package/dist/platform/telemetry-tracing.cjs +602 -0
- package/dist/platform/telemetry-tracing.mjs +596 -0
- package/dist/platform/telemetry-uploader.cjs +150 -0
- package/dist/platform/telemetry-uploader.mjs +146 -0
- package/dist/platform/workspace-service.cjs +469 -0
- package/dist/platform/workspace-service.mjs +430 -0
- package/dist/runtime/builtin-pi-assets.mjs +33 -0
- package/dist/runtime/claude-model-catalog.cjs +102 -0
- package/dist/runtime/claude-model-catalog.mjs +99 -0
- package/dist/runtime/codex-model-catalog.cjs +108 -0
- package/dist/runtime/codex-model-catalog.mjs +105 -0
- package/dist/runtime/pi-bash-timeout-extension.mjs +60 -0
- package/dist/runtime/pi-bash-timeout-injection.mjs +60 -0
- package/dist/runtime/pi-bash-timeout.bundle.js +43 -0
- package/dist/runtime/pi-model-catalog.cjs +116 -0
- package/dist/runtime/pi-model-catalog.mjs +75 -0
- package/dist/runtime/pi-official-auth.mjs +480 -0
- package/dist/runtime/pi-provider-config.cjs +252 -0
- package/dist/runtime/pi-provider-config.mjs +239 -0
- package/dist/runtime/pi-rpc-client.cjs +255 -0
- package/dist/runtime/pi-rpc-client.mjs +251 -0
- package/dist/runtime/pi-subagent-injection.mjs +129 -0
- package/dist/runtime/pi-subagents.bundle.js +8990 -0
- package/dist/runtime/runtime-adapters.mjs +1019 -0
- package/dist/runtime/runtime-contracts.cjs +2 -0
- package/dist/runtime/runtime-contracts.mjs +1 -0
- package/dist/runtime/runtime-host.cjs +930 -0
- package/dist/runtime/runtime-host.mjs +922 -0
- package/dist/runtime/runtime-model-catalog.cjs +43 -0
- package/dist/runtime/runtime-model-catalog.mjs +40 -0
- package/dist/runtime/runtime-readiness.cjs +177 -0
- package/dist/runtime/runtime-readiness.mjs +133 -0
- package/dist/setup/bot-register.mjs +655 -0
- package/dist/setup/grant-scopes.mjs +133 -0
- package/dist/setup/run-credential-preflight.mjs +89 -0
- package/dist/setup/setup-agent-choice.mjs +172 -0
- package/dist/setup/setup-bind.mjs +337 -0
- package/dist/setup/setup-binding.mjs +52 -0
- package/package.json +112 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { TargetRootLayout } from "../platform/root-layout.mjs";
|
|
5
|
+
import { acquireProcessLock, inspectProcess } from "../platform/process-state.mjs";
|
|
6
|
+
import { targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
|
|
7
|
+
const JSON_KEYS = new Set([
|
|
8
|
+
"agentState", "status", "map", "replyctx", "botIdentity", "senderProfiles", "readReceipts", "pendingReact", "runtimeDeliveries", "inboxState", "freshnessState", "documentComments", "reminders", "interactions",
|
|
9
|
+
]);
|
|
10
|
+
const NDJSON_KEYS = new Set(["conversation", "inbox"]);
|
|
11
|
+
const INBOX_LOCK_TIMEOUT_MS = 2_000;
|
|
12
|
+
const INBOX_MALFORMED_LOCK_GRACE_MS = 5_000;
|
|
13
|
+
const INBOX_LOCK_OWNER_FILE = "owner.json";
|
|
14
|
+
function lstatIfExists(file) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.lstatSync(file);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return null;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function rejectSymlink(file) {
|
|
25
|
+
if (lstatIfExists(file)?.isSymbolicLink())
|
|
26
|
+
throw new Error(`state path must not be a symlink: ${file}`);
|
|
27
|
+
}
|
|
28
|
+
function mkdirSecure(dir, boundary) {
|
|
29
|
+
const relative = path.relative(boundary, dir);
|
|
30
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
31
|
+
throw new Error(`state path escapes boundary: ${dir}`);
|
|
32
|
+
rejectSymlink(boundary);
|
|
33
|
+
fs.mkdirSync(boundary, { recursive: true, mode: 0o700 });
|
|
34
|
+
let cursor = boundary;
|
|
35
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
36
|
+
cursor = path.join(cursor, segment);
|
|
37
|
+
rejectSymlink(cursor);
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(cursor, { mode: 0o700 });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code !== "EEXIST")
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
rejectSymlink(cursor);
|
|
46
|
+
if (!fs.statSync(cursor).isDirectory())
|
|
47
|
+
throw new Error(`state path is not a directory: ${cursor}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function validateExistingPath(file, boundary) {
|
|
51
|
+
const relative = path.relative(boundary, file);
|
|
52
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
53
|
+
throw new Error(`state path escapes boundary: ${file}`);
|
|
54
|
+
let cursor = boundary;
|
|
55
|
+
const boundaryStat = lstatIfExists(cursor);
|
|
56
|
+
if (!boundaryStat)
|
|
57
|
+
return;
|
|
58
|
+
if (boundaryStat.isSymbolicLink())
|
|
59
|
+
throw new Error(`state path must not be a symlink: ${cursor}`);
|
|
60
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
61
|
+
cursor = path.join(cursor, segment);
|
|
62
|
+
const stat = lstatIfExists(cursor);
|
|
63
|
+
if (!stat)
|
|
64
|
+
return;
|
|
65
|
+
if (stat.isSymbolicLink())
|
|
66
|
+
throw new Error(`state path must not be a symlink: ${cursor}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function stringifyJson(value, pretty) {
|
|
70
|
+
const text = JSON.stringify(value, null, pretty ? 2 : undefined);
|
|
71
|
+
if (text === undefined)
|
|
72
|
+
throw new Error("state value must be JSON serializable");
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
function sleepSync(milliseconds) {
|
|
76
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
77
|
+
}
|
|
78
|
+
function emptyInboxState() {
|
|
79
|
+
return { version: 2, targets: {}, messages: {}, drafts: {}, intents: {} };
|
|
80
|
+
}
|
|
81
|
+
function validSequence(value) {
|
|
82
|
+
return Number.isSafeInteger(value) && Number(value) > 0;
|
|
83
|
+
}
|
|
84
|
+
function normalizeInboxState(value) {
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
86
|
+
return emptyInboxState();
|
|
87
|
+
const raw = value;
|
|
88
|
+
const state = emptyInboxState();
|
|
89
|
+
if (raw.targets && typeof raw.targets === "object" && !Array.isArray(raw.targets)) {
|
|
90
|
+
for (const [target, candidate] of Object.entries(raw.targets)) {
|
|
91
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
92
|
+
continue;
|
|
93
|
+
const row = candidate;
|
|
94
|
+
const latest = validSequence(row.latest_received_seq) ? row.latest_received_seq : 0;
|
|
95
|
+
const seen = validSequence(row.model_seen_seq) ? Math.min(row.model_seen_seq, latest) : 0;
|
|
96
|
+
state.targets[target] = { latest_received_seq: latest, model_seen_seq: seen };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (raw.messages && typeof raw.messages === "object" && !Array.isArray(raw.messages)) {
|
|
100
|
+
for (const [messageId, candidate] of Object.entries(raw.messages)) {
|
|
101
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
102
|
+
continue;
|
|
103
|
+
const row = candidate;
|
|
104
|
+
if (typeof row.target === "string" && row.target && validSequence(row.seq))
|
|
105
|
+
state.messages[messageId] = { target: row.target, seq: row.seq };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (raw.drafts && typeof raw.drafts === "object" && !Array.isArray(raw.drafts)) {
|
|
109
|
+
for (const [draftId, candidate] of Object.entries(raw.drafts)) {
|
|
110
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
111
|
+
continue;
|
|
112
|
+
const draft = candidate;
|
|
113
|
+
if (draft.draft_id === draftId && typeof draft.target === "string" && Array.isArray(draft.argv)
|
|
114
|
+
&& draft.argv.every((argument) => typeof argument === "string") && ["held", "sending", "sent", "abandoned"].includes(draft.status)) {
|
|
115
|
+
const normalized = { ...draft, argv: [...draft.argv] };
|
|
116
|
+
if (normalized.status === "sending" && (typeof normalized.intent_id !== "string" || !normalized.intent_id
|
|
117
|
+
|| !Number.isSafeInteger(normalized.intent_model_seen_seq) || Number(normalized.intent_model_seen_seq) < 0
|
|
118
|
+
|| typeof normalized.sending_at !== "string" || !normalized.sending_at)) {
|
|
119
|
+
normalized.status = "held";
|
|
120
|
+
delete normalized.intent_id;
|
|
121
|
+
delete normalized.intent_model_seen_seq;
|
|
122
|
+
delete normalized.sending_at;
|
|
123
|
+
}
|
|
124
|
+
state.drafts[draftId] = normalized;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (raw.intents && typeof raw.intents === "object" && !Array.isArray(raw.intents)) {
|
|
129
|
+
for (const [intentId, candidate] of Object.entries(raw.intents)) {
|
|
130
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
131
|
+
continue;
|
|
132
|
+
const intent = candidate;
|
|
133
|
+
if (intent.intent_id === intentId && typeof intent.target === "string" && !!intent.target
|
|
134
|
+
&& Array.isArray(intent.argv) && intent.argv.every((argument) => typeof argument === "string")
|
|
135
|
+
&& Number.isSafeInteger(intent.latest_received_seq) && intent.latest_received_seq >= 0
|
|
136
|
+
&& Number.isSafeInteger(intent.model_seen_seq) && intent.model_seen_seq >= 0
|
|
137
|
+
&& typeof intent.committed_at === "string" && !!intent.committed_at
|
|
138
|
+
&& (intent.draft_id === undefined || typeof intent.draft_id === "string")) {
|
|
139
|
+
state.intents[intentId] = { ...intent, argv: [...intent.argv] };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return state;
|
|
144
|
+
}
|
|
145
|
+
function newInboxLockOwner(inspect) {
|
|
146
|
+
const inspected = inspect(process.pid);
|
|
147
|
+
if (!inspected.ok || !inspected.startToken) {
|
|
148
|
+
throw new Error(`无法读取 Inbox lock owner 身份:${inspected.reason || "metadata incomplete"}`);
|
|
149
|
+
}
|
|
150
|
+
return { version: 1, pid: process.pid, processStartToken: inspected.startToken, nonce: crypto.randomUUID() };
|
|
151
|
+
}
|
|
152
|
+
function validInboxLockOwner(value) {
|
|
153
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
154
|
+
return false;
|
|
155
|
+
const owner = value;
|
|
156
|
+
return owner.version === 1 && Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0
|
|
157
|
+
&& typeof owner.processStartToken === "string" && !!owner.processStartToken
|
|
158
|
+
&& typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
|
|
159
|
+
}
|
|
160
|
+
export class AgentStateStore {
|
|
161
|
+
paths;
|
|
162
|
+
boundary;
|
|
163
|
+
inspect;
|
|
164
|
+
constructor(layout, agentId, dependencies = {}) {
|
|
165
|
+
this.paths = layout.agentStatePaths(agentId);
|
|
166
|
+
this.boundary = path.join(layout.root, "state");
|
|
167
|
+
this.inspect = dependencies.inspectProcess ?? inspectProcess;
|
|
168
|
+
}
|
|
169
|
+
file(key) {
|
|
170
|
+
if (!JSON_KEYS.has(key) && !NDJSON_KEYS.has(key))
|
|
171
|
+
throw new Error(`unknown Agent state key: ${key}`);
|
|
172
|
+
const file = this.paths[key];
|
|
173
|
+
const relative = path.relative(this.paths.root, file);
|
|
174
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
|
|
175
|
+
throw new Error(`state file escapes Agent root: ${file}`);
|
|
176
|
+
return file;
|
|
177
|
+
}
|
|
178
|
+
prepare(file) {
|
|
179
|
+
mkdirSecure(this.paths.root, this.boundary);
|
|
180
|
+
rejectSymlink(file);
|
|
181
|
+
}
|
|
182
|
+
readInboxLockOwner(lockDir) {
|
|
183
|
+
const ownerFile = path.join(lockDir, INBOX_LOCK_OWNER_FILE);
|
|
184
|
+
let fd = null;
|
|
185
|
+
try {
|
|
186
|
+
fd = fs.openSync(ownerFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
187
|
+
const stat = fs.fstatSync(fd);
|
|
188
|
+
if (!stat.isFile() || (typeof process.getuid === "function" && stat.uid !== process.getuid()) || (stat.mode & 0o077) !== 0) {
|
|
189
|
+
throw new Error(`Inbox lock owner 文件不安全:${ownerFile}`);
|
|
190
|
+
}
|
|
191
|
+
const value = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
192
|
+
return validInboxLockOwner(value) ? value : null;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError)
|
|
196
|
+
return null;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
if (fd !== null)
|
|
201
|
+
fs.closeSync(fd);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
inboxLockState(lockDir) {
|
|
205
|
+
const owner = this.readInboxLockOwner(lockDir);
|
|
206
|
+
if (!owner) {
|
|
207
|
+
const stat = lstatIfExists(lockDir);
|
|
208
|
+
if (!stat)
|
|
209
|
+
return "reclaimable";
|
|
210
|
+
const age = Date.now() - stat.mtimeMs;
|
|
211
|
+
return age >= INBOX_MALFORMED_LOCK_GRACE_MS ? "reclaimable" : "unknown";
|
|
212
|
+
}
|
|
213
|
+
const inspected = this.inspect(owner.pid);
|
|
214
|
+
if (!inspected.ok)
|
|
215
|
+
return inspected.dead ? "reclaimable" : "unknown";
|
|
216
|
+
if (!inspected.startToken)
|
|
217
|
+
return "unknown";
|
|
218
|
+
return inspected.startToken === owner.processStartToken ? "active" : "reclaimable";
|
|
219
|
+
}
|
|
220
|
+
removeInboxLockIfOwned(lockDir, owner) {
|
|
221
|
+
const current = this.readInboxLockOwner(lockDir);
|
|
222
|
+
if (!current || current.pid !== owner.pid || current.nonce !== owner.nonce
|
|
223
|
+
|| current.processStartToken !== owner.processStartToken)
|
|
224
|
+
return;
|
|
225
|
+
fs.unlinkSync(path.join(lockDir, INBOX_LOCK_OWNER_FILE));
|
|
226
|
+
fs.rmdirSync(lockDir);
|
|
227
|
+
}
|
|
228
|
+
tryReclaimInboxLock(lockDir) {
|
|
229
|
+
const reclaimFile = `${lockDir}.reclaim`;
|
|
230
|
+
rejectSymlink(reclaimFile);
|
|
231
|
+
let reclaim;
|
|
232
|
+
try {
|
|
233
|
+
reclaim = acquireProcessLock(reclaimFile, path.basename(process.execPath), {
|
|
234
|
+
malformedGraceMs: INBOX_MALFORMED_LOCK_GRACE_MS,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (/lock 已被|无法取得 lock|正在创建|暂不能接管/.test(error instanceof Error ? error.message : String(error)))
|
|
239
|
+
return false;
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
let stat;
|
|
244
|
+
try {
|
|
245
|
+
stat = fs.lstatSync(lockDir);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error.code === "ENOENT")
|
|
249
|
+
return true;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()
|
|
253
|
+
|| (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
|
|
254
|
+
throw new Error(`invalid inbox lock path: ${lockDir}`);
|
|
255
|
+
}
|
|
256
|
+
if (this.inboxLockState(lockDir) !== "reclaimable")
|
|
257
|
+
return false;
|
|
258
|
+
let entries;
|
|
259
|
+
try {
|
|
260
|
+
entries = fs.readdirSync(lockDir);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error.code === "ENOENT") {
|
|
264
|
+
if (!lstatIfExists(lockDir))
|
|
265
|
+
return true;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
if (entries.some((entry) => entry !== INBOX_LOCK_OWNER_FILE)) {
|
|
271
|
+
throw new Error(`Inbox lock 包含未知内容,拒绝回收:${lockDir}`);
|
|
272
|
+
}
|
|
273
|
+
if (entries.includes(INBOX_LOCK_OWNER_FILE)) {
|
|
274
|
+
const ownerFile = path.join(lockDir, INBOX_LOCK_OWNER_FILE);
|
|
275
|
+
const ownerStat = lstatIfExists(ownerFile);
|
|
276
|
+
if (!ownerStat) {
|
|
277
|
+
if (!lstatIfExists(lockDir))
|
|
278
|
+
return true;
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (!ownerStat.isFile() || ownerStat.isSymbolicLink())
|
|
282
|
+
throw new Error(`Inbox lock owner 路径无效:${lockDir}`);
|
|
283
|
+
try {
|
|
284
|
+
fs.unlinkSync(ownerFile);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error.code === "ENOENT") {
|
|
288
|
+
if (!lstatIfExists(lockDir))
|
|
289
|
+
return true;
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
fs.rmdirSync(lockDir);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (error.code === "ENOENT") {
|
|
300
|
+
if (!lstatIfExists(lockDir))
|
|
301
|
+
return true;
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
reclaim.release();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
withInboxLock(file, operation) {
|
|
313
|
+
this.prepare(file);
|
|
314
|
+
const lockDir = `${file}.lock`;
|
|
315
|
+
const relative = path.relative(this.paths.root, lockDir);
|
|
316
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
317
|
+
throw new Error(`inbox lock escapes Agent root: ${lockDir}`);
|
|
318
|
+
}
|
|
319
|
+
const reclaimFile = `${lockDir}.reclaim`;
|
|
320
|
+
const deadline = Date.now() + INBOX_LOCK_TIMEOUT_MS;
|
|
321
|
+
const owner = newInboxLockOwner(this.inspect);
|
|
322
|
+
let acquired = false;
|
|
323
|
+
for (;;) {
|
|
324
|
+
rejectSymlink(lockDir);
|
|
325
|
+
rejectSymlink(reclaimFile);
|
|
326
|
+
const reclaimStat = lstatIfExists(reclaimFile);
|
|
327
|
+
if (reclaimStat) {
|
|
328
|
+
if (!reclaimStat.isFile() || (typeof process.getuid === "function" && reclaimStat.uid !== process.getuid())) {
|
|
329
|
+
throw new Error(`invalid inbox reclaim lock path: ${reclaimFile}`);
|
|
330
|
+
}
|
|
331
|
+
this.tryReclaimInboxLock(lockDir);
|
|
332
|
+
if (lstatIfExists(reclaimFile)) {
|
|
333
|
+
if (Date.now() >= deadline)
|
|
334
|
+
throw new Error("Inbox 锁等待超时");
|
|
335
|
+
sleepSync(50);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
fs.mkdirSync(lockDir, { mode: 0o700 });
|
|
341
|
+
const stat = fs.lstatSync(lockDir);
|
|
342
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() ||
|
|
343
|
+
(typeof process.getuid === "function" && stat.uid !== process.getuid())) {
|
|
344
|
+
throw new Error(`inbox lock is not an owned directory: ${lockDir}`);
|
|
345
|
+
}
|
|
346
|
+
const ownerFile = path.join(lockDir, INBOX_LOCK_OWNER_FILE);
|
|
347
|
+
const fd = fs.openSync(ownerFile, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
348
|
+
try {
|
|
349
|
+
fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`);
|
|
350
|
+
fs.fsyncSync(fd);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
fs.closeSync(fd);
|
|
354
|
+
}
|
|
355
|
+
if (lstatIfExists(reclaimFile)) {
|
|
356
|
+
this.removeInboxLockIfOwned(lockDir, owner);
|
|
357
|
+
throw Object.assign(new Error("Inbox lock 正在回收"), { code: "EEXIST" });
|
|
358
|
+
}
|
|
359
|
+
const current = this.readInboxLockOwner(lockDir);
|
|
360
|
+
if (!current || current.nonce !== owner.nonce || current.pid !== owner.pid) {
|
|
361
|
+
throw new Error("Inbox lock owner 发布后不一致");
|
|
362
|
+
}
|
|
363
|
+
acquired = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (error.code !== "EEXIST")
|
|
368
|
+
throw error;
|
|
369
|
+
const stat = lstatIfExists(lockDir);
|
|
370
|
+
if (stat) {
|
|
371
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
372
|
+
throw new Error(`invalid inbox lock path: ${lockDir}`);
|
|
373
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
374
|
+
throw new Error(`inbox lock is not owned by current user: ${lockDir}`);
|
|
375
|
+
}
|
|
376
|
+
if (this.inboxLockState(lockDir) === "reclaimable" && this.tryReclaimInboxLock(lockDir))
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (Date.now() >= deadline)
|
|
380
|
+
throw new Error("Inbox 锁等待超时");
|
|
381
|
+
sleepSync(50);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
return operation();
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
if (acquired)
|
|
389
|
+
this.removeInboxLockIfOwned(lockDir, owner);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Share the canonical Inbox/delivery-state transaction boundary with RuntimeHost. */
|
|
393
|
+
withInboxTransaction(operation) {
|
|
394
|
+
return this.withInboxLock(this.file("inbox"), operation);
|
|
395
|
+
}
|
|
396
|
+
inboxState() {
|
|
397
|
+
return normalizeInboxState(this.readJson("inboxState", emptyInboxState()));
|
|
398
|
+
}
|
|
399
|
+
readFreshnessCursor(target, generation = "external") {
|
|
400
|
+
const state = this.readJson("freshnessState", { version: 1, cursors: {} });
|
|
401
|
+
if (state.version !== 1 || !state.cursors || typeof state.cursors !== "object" || Array.isArray(state.cursors))
|
|
402
|
+
return null;
|
|
403
|
+
const record = state.cursors[target];
|
|
404
|
+
if (!record || typeof record !== "object" || Array.isArray(record))
|
|
405
|
+
return null;
|
|
406
|
+
const row = record;
|
|
407
|
+
return row.generation === generation && row.cursor !== undefined ? row.cursor : null;
|
|
408
|
+
}
|
|
409
|
+
mergeFreshnessCursor(target, cursor, merge, generation = "external") {
|
|
410
|
+
return this.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
|
|
411
|
+
if (state.version !== 1 || !state.cursors || typeof state.cursors !== "object" || Array.isArray(state.cursors)) {
|
|
412
|
+
state.version = 1;
|
|
413
|
+
state.cursors = {};
|
|
414
|
+
}
|
|
415
|
+
const previous = state.cursors[target];
|
|
416
|
+
const next = merge(previous?.generation === generation ? previous.cursor : null, cursor);
|
|
417
|
+
state.cursors[target] = { generation, cursor: next };
|
|
418
|
+
return next;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
normalizeInboxEnvelope(value, state) {
|
|
422
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
423
|
+
throw new Error("Inbox envelope must be an object");
|
|
424
|
+
const input = value;
|
|
425
|
+
const target = typeof input.target === "string" && input.target ? input.target : targetKeyOfInboxEnvelope(input);
|
|
426
|
+
const current = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
|
|
427
|
+
const requested = validSequence(input.target_seq) ? input.target_seq : 0;
|
|
428
|
+
const targetSeq = requested > current.latest_received_seq ? requested : current.latest_received_seq + 1;
|
|
429
|
+
state.targets[target] = { ...current, latest_received_seq: targetSeq };
|
|
430
|
+
if (typeof input.message_id === "string" && input.message_id) {
|
|
431
|
+
state.messages[input.message_id] = { target, seq: targetSeq };
|
|
432
|
+
const messageIds = Object.keys(state.messages);
|
|
433
|
+
for (const stale of messageIds.slice(0, Math.max(0, messageIds.length - 2_048)))
|
|
434
|
+
delete state.messages[stale];
|
|
435
|
+
}
|
|
436
|
+
return { ...input, envelope_version: 2, target, target_seq: targetSeq };
|
|
437
|
+
}
|
|
438
|
+
reconcileInboxRows(rows, state) {
|
|
439
|
+
return rows.map((row) => {
|
|
440
|
+
const target = typeof row.target === "string" && row.target ? row.target : targetKeyOfInboxEnvelope(row);
|
|
441
|
+
const existing = typeof row.message_id === "string" ? state.messages[row.message_id] : undefined;
|
|
442
|
+
const current = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
|
|
443
|
+
const targetSeq = validSequence(row.target_seq) ? row.target_seq
|
|
444
|
+
: existing?.target === target ? existing.seq : current.latest_received_seq + 1;
|
|
445
|
+
current.latest_received_seq = Math.max(current.latest_received_seq, targetSeq);
|
|
446
|
+
state.targets[target] = current;
|
|
447
|
+
if (typeof row.message_id === "string" && row.message_id)
|
|
448
|
+
state.messages[row.message_id] = { target, seq: targetSeq };
|
|
449
|
+
return { ...row, envelope_version: 2, target, target_seq: targetSeq };
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
appendInboxUnlocked(value) {
|
|
453
|
+
const file = this.file("inbox");
|
|
454
|
+
const state = this.inboxState();
|
|
455
|
+
const envelope = this.normalizeInboxEnvelope(value, state);
|
|
456
|
+
this.prepare(file);
|
|
457
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
|
458
|
+
try {
|
|
459
|
+
fs.writeFileSync(fd, `${stringifyJson(envelope, false)}\n`);
|
|
460
|
+
fs.fsyncSync(fd);
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
fs.closeSync(fd);
|
|
464
|
+
}
|
|
465
|
+
fs.chmodSync(file, 0o600);
|
|
466
|
+
this.writeJson("inboxState", state);
|
|
467
|
+
return envelope;
|
|
468
|
+
}
|
|
469
|
+
replaceInboxUnlocked(rows) {
|
|
470
|
+
const file = this.file("inbox");
|
|
471
|
+
this.prepare(file);
|
|
472
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
|
473
|
+
try {
|
|
474
|
+
const text = rows.length ? `${rows.map((row) => stringifyJson(row, false)).join("\n")}\n` : "";
|
|
475
|
+
fs.writeFileSync(fd, text);
|
|
476
|
+
fs.fsyncSync(fd);
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
fs.closeSync(fd);
|
|
480
|
+
}
|
|
481
|
+
fs.chmodSync(file, 0o600);
|
|
482
|
+
}
|
|
483
|
+
readText(file) {
|
|
484
|
+
validateExistingPath(file, this.boundary);
|
|
485
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
486
|
+
try {
|
|
487
|
+
const stat = fs.fstatSync(fd);
|
|
488
|
+
if (!stat.isFile() || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
|
|
489
|
+
throw new Error(`state path is not an owned regular file: ${file}`);
|
|
490
|
+
}
|
|
491
|
+
return fs.readFileSync(fd, "utf8");
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
fs.closeSync(fd);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
readJson(key, fallback) {
|
|
498
|
+
if (!JSON_KEYS.has(key))
|
|
499
|
+
throw new Error(`unknown JSON state key: ${key}`);
|
|
500
|
+
const file = this.file(key);
|
|
501
|
+
try {
|
|
502
|
+
return JSON.parse(this.readText(file));
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (error.code === "ENOENT")
|
|
506
|
+
return fallback;
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
writeJson(key, value) {
|
|
511
|
+
if (!JSON_KEYS.has(key))
|
|
512
|
+
throw new Error(`unknown JSON state key: ${key}`);
|
|
513
|
+
const file = this.file(key);
|
|
514
|
+
this.prepare(file);
|
|
515
|
+
const temporary = path.join(this.paths.root, `.${path.basename(file)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
516
|
+
let fd;
|
|
517
|
+
try {
|
|
518
|
+
fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
519
|
+
fs.writeFileSync(fd, `${stringifyJson(value, true)}\n`);
|
|
520
|
+
fs.fsyncSync(fd);
|
|
521
|
+
fs.closeSync(fd);
|
|
522
|
+
fd = undefined;
|
|
523
|
+
rejectSymlink(file);
|
|
524
|
+
fs.renameSync(temporary, file);
|
|
525
|
+
fs.chmodSync(file, 0o600);
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
if (fd !== undefined)
|
|
529
|
+
fs.closeSync(fd);
|
|
530
|
+
try {
|
|
531
|
+
fs.unlinkSync(temporary);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (error.code !== "ENOENT")
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** Serialize a read-modify-write transaction for one JSON state file across Host and Agent CLI processes. */
|
|
540
|
+
mutateJson(key, fallback, operation) {
|
|
541
|
+
const file = this.file(key);
|
|
542
|
+
return this.withInboxLock(file, () => {
|
|
543
|
+
const value = this.readJson(key, fallback);
|
|
544
|
+
const result = operation(value);
|
|
545
|
+
this.writeJson(key, value);
|
|
546
|
+
return result;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
appendNdjson(key, value) {
|
|
550
|
+
if (!NDJSON_KEYS.has(key))
|
|
551
|
+
throw new Error(`unknown NDJSON state key: ${key}`);
|
|
552
|
+
const file = this.file(key);
|
|
553
|
+
const append = () => {
|
|
554
|
+
if (key === "inbox") {
|
|
555
|
+
this.appendInboxUnlocked(value);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.prepare(file);
|
|
559
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
|
560
|
+
try {
|
|
561
|
+
fs.writeFileSync(fd, `${stringifyJson(value, false)}\n`);
|
|
562
|
+
fs.fsyncSync(fd);
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
fs.closeSync(fd);
|
|
566
|
+
}
|
|
567
|
+
fs.chmodSync(file, 0o600);
|
|
568
|
+
};
|
|
569
|
+
if (key === "inbox")
|
|
570
|
+
this.withInboxLock(file, append);
|
|
571
|
+
else
|
|
572
|
+
append();
|
|
573
|
+
}
|
|
574
|
+
/** Append a canonical Inbox envelope once by stable message_id under the shared cross-process lock. */
|
|
575
|
+
appendInboxOnce(value) {
|
|
576
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
577
|
+
throw new Error("Inbox envelope must be an object");
|
|
578
|
+
const messageId = value.message_id;
|
|
579
|
+
if (typeof messageId !== "string" || !messageId)
|
|
580
|
+
throw new Error("Inbox envelope requires message_id");
|
|
581
|
+
const file = this.file("inbox");
|
|
582
|
+
return this.withInboxLock(file, () => {
|
|
583
|
+
if (this.inboxState().messages[messageId])
|
|
584
|
+
return false;
|
|
585
|
+
if (this.readNdjson("inbox").some((row) => row.message_id === messageId))
|
|
586
|
+
return false;
|
|
587
|
+
this.appendInboxUnlocked(value);
|
|
588
|
+
return true;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Recoverably bridge an outbox wake into the canonical Inbox. A durable
|
|
593
|
+
* Runtime ledger is authoritative after the Inbox has been drained, so a
|
|
594
|
+
* post-delivery crash cannot re-append the same synthetic wake on restart.
|
|
595
|
+
*/
|
|
596
|
+
prepareInboxDelivery(value) {
|
|
597
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
598
|
+
throw new Error("Inbox envelope must be an object");
|
|
599
|
+
const messageId = value.message_id;
|
|
600
|
+
if (typeof messageId !== "string" || !messageId)
|
|
601
|
+
throw new Error("Inbox envelope requires message_id");
|
|
602
|
+
const file = this.file("inbox");
|
|
603
|
+
return this.withInboxLock(file, () => {
|
|
604
|
+
const ledger = this.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
605
|
+
if (!Array.isArray(ledger.records))
|
|
606
|
+
throw new Error("runtime-deliveries.json records 必须是数组");
|
|
607
|
+
const delivery = ledger.records.find((candidate) => candidate && typeof candidate === "object"
|
|
608
|
+
&& !Array.isArray(candidate) && candidate.messageId === messageId);
|
|
609
|
+
if (delivery?.status === "consumed")
|
|
610
|
+
return "consumed";
|
|
611
|
+
if (["pending", "submitting", "accepted"].includes(String(delivery?.status || "")))
|
|
612
|
+
return "active";
|
|
613
|
+
if (delivery?.status === "error")
|
|
614
|
+
return "terminal_error";
|
|
615
|
+
if (this.readNdjson("inbox").some((row) => row.message_id === messageId))
|
|
616
|
+
return "present";
|
|
617
|
+
this.appendInboxUnlocked(value);
|
|
618
|
+
return "appended";
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
readNdjson(key) {
|
|
622
|
+
if (!NDJSON_KEYS.has(key))
|
|
623
|
+
throw new Error(`unknown NDJSON state key: ${key}`);
|
|
624
|
+
const file = this.file(key);
|
|
625
|
+
let text;
|
|
626
|
+
try {
|
|
627
|
+
text = this.readText(file);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
if (error.code === "ENOENT")
|
|
631
|
+
return [];
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
return text.split("\n").filter(Boolean).map((line, index) => {
|
|
635
|
+
try {
|
|
636
|
+
return JSON.parse(line);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
throw new Error(`invalid NDJSON at ${file}:${index + 1}: ${error.message}`);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Atomically parse and drain the current Inbox batch relative to all
|
|
645
|
+
* AgentStateStore Inbox appenders. Parsing completes before truncation, so a
|
|
646
|
+
* malformed batch is left byte-for-byte intact. An appender arriving while
|
|
647
|
+
* this transaction is active waits and becomes part of the next batch.
|
|
648
|
+
*/
|
|
649
|
+
pollInbox(options = {}) {
|
|
650
|
+
const file = this.file("inbox");
|
|
651
|
+
return this.withInboxLock(file, () => {
|
|
652
|
+
let rawRows;
|
|
653
|
+
try {
|
|
654
|
+
rawRows = this.readNdjson("inbox");
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
options.afterRead?.();
|
|
660
|
+
const state = this.inboxState();
|
|
661
|
+
const rows = this.reconcileInboxRows(rawRows, state);
|
|
662
|
+
const limit = options.limit === undefined ? 100 : options.limit;
|
|
663
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
664
|
+
throw new Error("inbox poll --limit 必须是 1..100 的整数");
|
|
665
|
+
const envelopes = [];
|
|
666
|
+
const remaining = [];
|
|
667
|
+
for (const row of rows) {
|
|
668
|
+
const selectedTarget = typeof row.target === "string" ? row.target : targetKeyOfInboxEnvelope(row);
|
|
669
|
+
if (envelopes.length < limit && (!options.target || options.target === selectedTarget))
|
|
670
|
+
envelopes.push(row);
|
|
671
|
+
else
|
|
672
|
+
remaining.push(row);
|
|
673
|
+
}
|
|
674
|
+
const pendingCount = remaining.reduce((count, row) => {
|
|
675
|
+
if (!options.target)
|
|
676
|
+
return count + 1;
|
|
677
|
+
const rowTarget = typeof row.target === "string" ? row.target : targetKeyOfInboxEnvelope(row);
|
|
678
|
+
return count + (rowTarget === options.target ? 1 : 0);
|
|
679
|
+
}, 0);
|
|
680
|
+
if (!envelopes.length)
|
|
681
|
+
return { envelopes, consumedDeliveryIds: [], seenThroughSeq: null, pendingCount };
|
|
682
|
+
const messageIds = new Set(envelopes.flatMap((row) => {
|
|
683
|
+
if (!row || typeof row !== "object")
|
|
684
|
+
return [];
|
|
685
|
+
const messageId = row.message_id;
|
|
686
|
+
return typeof messageId === "string" && messageId ? [messageId] : [];
|
|
687
|
+
}));
|
|
688
|
+
const consumedDeliveryIds = [];
|
|
689
|
+
if (messageIds.size) {
|
|
690
|
+
const deliveries = this.readJson("runtimeDeliveries", { version: 1, records: [] });
|
|
691
|
+
if (!Array.isArray(deliveries.records))
|
|
692
|
+
throw new Error("runtime-deliveries.json records 必须是数组");
|
|
693
|
+
let changed = false;
|
|
694
|
+
const updatedAt = new Date((options.now ?? Date.now)()).toISOString();
|
|
695
|
+
const records = deliveries.records.map((rawRecord) => {
|
|
696
|
+
if (!rawRecord || typeof rawRecord !== "object" || Array.isArray(rawRecord))
|
|
697
|
+
return rawRecord;
|
|
698
|
+
const record = rawRecord;
|
|
699
|
+
if (typeof record.messageId !== "string" || !messageIds.has(record.messageId) || record.status === "consumed")
|
|
700
|
+
return rawRecord;
|
|
701
|
+
changed = true;
|
|
702
|
+
if (typeof record.deliveryId === "string")
|
|
703
|
+
consumedDeliveryIds.push(record.deliveryId);
|
|
704
|
+
return { ...record, status: "consumed", updatedAt };
|
|
705
|
+
});
|
|
706
|
+
if (changed)
|
|
707
|
+
this.writeJson("runtimeDeliveries", { ...deliveries, records });
|
|
708
|
+
}
|
|
709
|
+
let seenThroughSeq = null;
|
|
710
|
+
for (const envelope of envelopes) {
|
|
711
|
+
const target = String(envelope.target || targetKeyOfInboxEnvelope(envelope));
|
|
712
|
+
const targetSeq = Number(envelope.target_seq);
|
|
713
|
+
const targetState = state.targets[target] ?? { latest_received_seq: targetSeq, model_seen_seq: 0 };
|
|
714
|
+
if (validSequence(targetSeq)) {
|
|
715
|
+
targetState.latest_received_seq = Math.max(targetState.latest_received_seq, targetSeq);
|
|
716
|
+
targetState.model_seen_seq = Math.max(targetState.model_seen_seq, targetSeq);
|
|
717
|
+
seenThroughSeq = seenThroughSeq === null ? targetSeq : Math.max(seenThroughSeq, targetSeq);
|
|
718
|
+
}
|
|
719
|
+
state.targets[target] = targetState;
|
|
720
|
+
}
|
|
721
|
+
this.replaceInboxUnlocked(remaining);
|
|
722
|
+
this.writeJson("inboxState", state);
|
|
723
|
+
return { envelopes, consumedDeliveryIds, seenThroughSeq, pendingCount };
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
drainInbox(hooks = {}) {
|
|
727
|
+
return this.pollInbox(hooks).envelopes;
|
|
728
|
+
}
|
|
729
|
+
resolveInboxMessageTarget(messageId) {
|
|
730
|
+
return this.withInboxLock(this.file("inbox"), () => {
|
|
731
|
+
const state = this.inboxState();
|
|
732
|
+
const known = state.messages[messageId]?.target;
|
|
733
|
+
if (known)
|
|
734
|
+
return known;
|
|
735
|
+
const row = this.readNdjson("inbox").find((candidate) => candidate.message_id === messageId);
|
|
736
|
+
return row ? (typeof row.target === "string" ? row.target : targetKeyOfInboxEnvelope(row)) : null;
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
listInboxDrafts() {
|
|
740
|
+
return this.withInboxLock(this.file("inbox"), () => Object.values(this.inboxState().drafts)
|
|
741
|
+
.filter((draft) => draft.status === "held" || draft.status === "sending")
|
|
742
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at)));
|
|
743
|
+
}
|
|
744
|
+
readInboxDraft(draftId) {
|
|
745
|
+
return this.withInboxLock(this.file("inbox"), () => this.inboxState().drafts[draftId] ?? null);
|
|
746
|
+
}
|
|
747
|
+
setInboxDraftStatus(draftId, status, now = Date.now()) {
|
|
748
|
+
return this.withInboxLock(this.file("inbox"), () => {
|
|
749
|
+
const state = this.inboxState();
|
|
750
|
+
const draft = state.drafts[draftId];
|
|
751
|
+
if (!draft)
|
|
752
|
+
throw new Error(`draft 不存在:${draftId}`);
|
|
753
|
+
if (draft.status === "sending")
|
|
754
|
+
throw new Error(`draft ${draftId} 正在发送,too late to abandon;可重试 send 以同一幂等键恢复`);
|
|
755
|
+
if (draft.status !== "held")
|
|
756
|
+
throw new Error(`draft ${draftId} 已是 ${draft.status}`);
|
|
757
|
+
const updated = { ...draft, status, updated_at: new Date(now).toISOString() };
|
|
758
|
+
state.drafts[draftId] = updated;
|
|
759
|
+
this.writeJson("inboxState", state);
|
|
760
|
+
return updated;
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
inboxTargetIsFresh(target) {
|
|
764
|
+
return this.withInboxLock(this.file("inbox"), () => {
|
|
765
|
+
const state = this.inboxState();
|
|
766
|
+
const targetState = state.targets[target] ?? { latest_received_seq: 0, model_seen_seq: 0 };
|
|
767
|
+
return targetState.latest_received_seq <= targetState.model_seen_seq;
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
withFreshnessGate(input, perform) {
|
|
771
|
+
const file = this.file("inbox");
|
|
772
|
+
const preflight = this.withInboxLock(file, () => {
|
|
773
|
+
const state = this.inboxState();
|
|
774
|
+
const rows = this.reconcileInboxRows(this.readNdjson("inbox"), state);
|
|
775
|
+
const pendingLatest = rows.filter((row) => row.target === input.target)
|
|
776
|
+
.reduce((latest, row) => validSequence(row.target_seq) ? Math.max(latest, row.target_seq) : latest, 0);
|
|
777
|
+
const target = state.targets[input.target] ?? { latest_received_seq: pendingLatest, model_seen_seq: 0 };
|
|
778
|
+
target.latest_received_seq = Math.max(target.latest_received_seq, pendingLatest);
|
|
779
|
+
state.targets[input.target] = target;
|
|
780
|
+
const draftFingerprint = crypto.createHash("sha256").update(JSON.stringify([input.target, input.argv])).digest("hex");
|
|
781
|
+
let commitDraft;
|
|
782
|
+
if (input.commitDraftId) {
|
|
783
|
+
if (!input.providerSucceeded)
|
|
784
|
+
throw new Error("commitDraftId requires providerSucceeded");
|
|
785
|
+
const draft = state.drafts[input.commitDraftId];
|
|
786
|
+
if (!draft || (draft.status !== "held" && draft.status !== "sending") || draft.target !== input.target
|
|
787
|
+
|| JSON.stringify(draft.argv) !== JSON.stringify(input.argv)) {
|
|
788
|
+
throw new Error(`held/sending draft 与 freshness intent 不匹配:${input.commitDraftId}`);
|
|
789
|
+
}
|
|
790
|
+
commitDraft = draft;
|
|
791
|
+
if (draft.status === "sending") {
|
|
792
|
+
const intentId = draft.intent_id;
|
|
793
|
+
const existingIntent = state.intents[intentId];
|
|
794
|
+
state.intents[intentId] = existingIntent ?? {
|
|
795
|
+
intent_id: intentId, target: input.target, argv: [...input.argv],
|
|
796
|
+
latest_received_seq: draft.intent_model_seen_seq, model_seen_seq: draft.intent_model_seen_seq,
|
|
797
|
+
committed_at: draft.sending_at, draft_id: draft.draft_id,
|
|
798
|
+
};
|
|
799
|
+
this.writeJson("inboxState", state);
|
|
800
|
+
return { status: "ready", intentId, commitDraftId: draft.draft_id };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (target.latest_received_seq > target.model_seen_seq) {
|
|
804
|
+
let draftId = commitDraft?.draft_id ?? `draft_${draftFingerprint.slice(0, 24)}`;
|
|
805
|
+
const at = new Date(input.now ?? Date.now()).toISOString();
|
|
806
|
+
let existing = state.drafts[draftId];
|
|
807
|
+
if (!commitDraft && existing && existing.status !== "held") {
|
|
808
|
+
const boundaryFingerprint = crypto.createHash("sha256")
|
|
809
|
+
.update(JSON.stringify([input.target, input.argv, target.latest_received_seq])).digest("hex");
|
|
810
|
+
draftId = `draft_${boundaryFingerprint.slice(0, 24)}`;
|
|
811
|
+
existing = state.drafts[draftId];
|
|
812
|
+
}
|
|
813
|
+
const draft = existing?.status === "held" ? {
|
|
814
|
+
...existing, held_at_seq: target.latest_received_seq, updated_at: at,
|
|
815
|
+
} : {
|
|
816
|
+
draft_id: draftId, target: input.target, argv: [...input.argv], status: "held",
|
|
817
|
+
held_at_seq: target.latest_received_seq, created_at: at, updated_at: at,
|
|
818
|
+
};
|
|
819
|
+
state.drafts[draftId] = draft;
|
|
820
|
+
this.writeJson("inboxState", state);
|
|
821
|
+
return { status: "held", target: input.target, latest_received_seq: target.latest_received_seq,
|
|
822
|
+
model_seen_seq: target.model_seen_seq, draft };
|
|
823
|
+
}
|
|
824
|
+
const fingerprint = crypto.createHash("sha256")
|
|
825
|
+
.update(JSON.stringify([input.target, input.argv, target.model_seen_seq])).digest("hex");
|
|
826
|
+
const intentId = `larkin-${fingerprint.slice(0, 32)}`;
|
|
827
|
+
const commitDraftId = commitDraft?.draft_id;
|
|
828
|
+
const committedAt = new Date(input.now ?? Date.now()).toISOString();
|
|
829
|
+
if (commitDraft) {
|
|
830
|
+
state.drafts[commitDraft.draft_id] = {
|
|
831
|
+
...commitDraft, status: "sending", intent_id: intentId,
|
|
832
|
+
intent_model_seen_seq: target.model_seen_seq, sending_at: committedAt, updated_at: committedAt,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
const existingIntent = state.intents[intentId];
|
|
836
|
+
state.intents[intentId] = existingIntent ? {
|
|
837
|
+
...existingIntent, ...(commitDraftId ? { draft_id: commitDraftId } : {}),
|
|
838
|
+
} : {
|
|
839
|
+
intent_id: intentId,
|
|
840
|
+
target: input.target,
|
|
841
|
+
argv: [...input.argv],
|
|
842
|
+
latest_received_seq: target.latest_received_seq,
|
|
843
|
+
model_seen_seq: target.model_seen_seq,
|
|
844
|
+
committed_at: committedAt,
|
|
845
|
+
...(commitDraftId ? { draft_id: commitDraftId } : {}),
|
|
846
|
+
};
|
|
847
|
+
const intentIds = Object.keys(state.intents)
|
|
848
|
+
.sort((left, right) => state.intents[left].committed_at.localeCompare(state.intents[right].committed_at));
|
|
849
|
+
for (const stale of intentIds.slice(0, Math.max(0, intentIds.length - 2_048)))
|
|
850
|
+
delete state.intents[stale];
|
|
851
|
+
this.writeJson("inboxState", state);
|
|
852
|
+
return { status: "ready", intentId, commitDraftId };
|
|
853
|
+
});
|
|
854
|
+
if (preflight.status === "held")
|
|
855
|
+
return preflight;
|
|
856
|
+
// Provider/network work starts only after the durable intent commit has
|
|
857
|
+
// released the Inbox lock. That commit is the local receive boundary.
|
|
858
|
+
const finalizeDraft = (succeeded) => {
|
|
859
|
+
if (!preflight.commitDraftId)
|
|
860
|
+
return;
|
|
861
|
+
this.withInboxLock(file, () => {
|
|
862
|
+
const state = this.inboxState();
|
|
863
|
+
const draft = state.drafts[preflight.commitDraftId];
|
|
864
|
+
if (succeeded && draft?.status === "sent" && draft.intent_id === preflight.intentId)
|
|
865
|
+
return;
|
|
866
|
+
if (!draft || draft.status !== "sending" || draft.intent_id !== preflight.intentId
|
|
867
|
+
|| draft.target !== input.target || JSON.stringify(draft.argv) !== JSON.stringify(input.argv)) {
|
|
868
|
+
throw new Error(`held draft finalize 状态不一致:${preflight.commitDraftId}`);
|
|
869
|
+
}
|
|
870
|
+
const updatedAt = new Date(input.now ?? Date.now()).toISOString();
|
|
871
|
+
if (succeeded)
|
|
872
|
+
state.drafts[draft.draft_id] = { ...draft, status: "sent", updated_at: updatedAt };
|
|
873
|
+
else {
|
|
874
|
+
const latest = state.targets[draft.target]?.latest_received_seq ?? draft.held_at_seq;
|
|
875
|
+
const { intent_id: _intentId, intent_model_seen_seq: _boundary, sending_at: _sendingAt, ...retryable } = draft;
|
|
876
|
+
state.drafts[draft.draft_id] = {
|
|
877
|
+
...retryable, status: "held", held_at_seq: Math.max(draft.held_at_seq, latest), updated_at: updatedAt,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
this.writeJson("inboxState", state);
|
|
881
|
+
});
|
|
882
|
+
};
|
|
883
|
+
let result;
|
|
884
|
+
try {
|
|
885
|
+
result = perform(preflight.intentId);
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
finalizeDraft(false);
|
|
889
|
+
throw error;
|
|
890
|
+
}
|
|
891
|
+
finalizeDraft(Boolean(input.providerSucceeded?.(result)));
|
|
892
|
+
return { status: "ready", target: input.target, intentId: preflight.intentId, result };
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Legacy explicit clear operation. New Inbox consumers must use pollInbox so
|
|
896
|
+
* a concurrent cooperating append cannot be truncated.
|
|
897
|
+
*/
|
|
898
|
+
clearNdjson(key) {
|
|
899
|
+
if (!NDJSON_KEYS.has(key))
|
|
900
|
+
throw new Error(`unknown NDJSON state key: ${key}`);
|
|
901
|
+
const file = this.file(key);
|
|
902
|
+
validateExistingPath(file, this.boundary);
|
|
903
|
+
const existing = lstatIfExists(file);
|
|
904
|
+
if (!existing)
|
|
905
|
+
return;
|
|
906
|
+
this.prepare(file);
|
|
907
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW);
|
|
908
|
+
try {
|
|
909
|
+
const stat = fs.fstatSync(fd);
|
|
910
|
+
if (!stat.isFile() || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
|
|
911
|
+
throw new Error(`state path is not an owned regular file: ${file}`);
|
|
912
|
+
}
|
|
913
|
+
fs.fsyncSync(fd);
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
fs.closeSync(fd);
|
|
917
|
+
}
|
|
918
|
+
fs.chmodSync(file, 0o600);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
export function createAgentStateStore(root, agentId, dependencies = {}) {
|
|
922
|
+
return new AgentStateStore(TargetRootLayout.fromConfigDir(root), agentId, dependencies);
|
|
923
|
+
}
|