@nowcrew/daemon 0.4.3 → 0.4.4
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/dist/config.js +1 -1
- package/dist/main.js +3 -0
- package/dist/prompt.js +4 -3
- package/dist/runner.js +24 -4
- package/dist/serve.js +63 -0
- package/dist/slog.js +214 -0
- package/package.json +2 -2
package/dist/config.js
CHANGED
|
@@ -33,6 +33,6 @@ export function loadConfig(env = process.env) {
|
|
|
33
33
|
dangerous: env.CREW_RUNTIME_SAFE !== "1", // 默认开启 (headless agent 在自有 workspace 内运行)
|
|
34
34
|
resume: env.CREW_RESUME !== "off" && env.CREW_RESUME !== "0", // 默认开启;一键回退现状用 CREW_RESUME=off
|
|
35
35
|
resumeWarmMs: env.CREW_RESUME_WARM_MS != null ? Number(env.CREW_RESUME_WARM_MS) : 3_600_000, // 默认 1h
|
|
36
|
-
productName: env.CREW_PRODUCT_NAME ?? "
|
|
36
|
+
productName: env.CREW_PRODUCT_NAME ?? "nowcrew",
|
|
37
37
|
};
|
|
38
38
|
}
|
package/dist/main.js
CHANGED
|
@@ -11,6 +11,7 @@ import { detectDaemonLang, translateDaemon } from "./i18n.js";
|
|
|
11
11
|
import { cliVersion, daemonVersion } from "./machine-info.js";
|
|
12
12
|
import { runAgent } from "./runner.js";
|
|
13
13
|
import { serve } from "./serve.js";
|
|
14
|
+
import { initSlog, flushSlog } from "./slog.js";
|
|
14
15
|
async function main() {
|
|
15
16
|
const lang = detectDaemonLang();
|
|
16
17
|
const td = (message) => translateDaemon(lang, message);
|
|
@@ -64,6 +65,7 @@ async function main() {
|
|
|
64
65
|
process.exit(2);
|
|
65
66
|
}
|
|
66
67
|
process.stdout.write(`\n🚀 ${td("Waking agent")} "${values.agent}" ${td("for channel")} ${values.channel}\n\n`);
|
|
68
|
+
initSlog(config.serverUrl, config.machineToken); // 一次性 run 模式也上报 SLS(runner 里的埋点生效)
|
|
67
69
|
const result = await runAgent(config, {
|
|
68
70
|
handle: values.agent,
|
|
69
71
|
channelId: values.channel,
|
|
@@ -71,6 +73,7 @@ async function main() {
|
|
|
71
73
|
...(values.display ? { displayName: values.display } : {}),
|
|
72
74
|
});
|
|
73
75
|
process.stdout.write(`\n— ${td("agent exited")} (code ${result.exitCode}), ${td("activities")}: ${result.activities.length} —\n`);
|
|
76
|
+
await flushSlog();
|
|
74
77
|
process.exit(result.exitCode);
|
|
75
78
|
}
|
|
76
79
|
main().catch((e) => {
|
package/dist/prompt.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 系统提示词与唤醒提示词构造(
|
|
2
|
+
* 系统提示词与唤醒提示词构造(nowcrew 自有设计)。
|
|
3
3
|
*
|
|
4
|
-
* 描述的是
|
|
4
|
+
* 描述的是 nowcrew 自身的 crew CLI 命令面与运行约定:crew-only 通信、一命令一调用、
|
|
5
5
|
* claim-before-work、freshness/draft、"做完所有事再停"、inbox notice 语义、
|
|
6
6
|
* 分层记忆与压缩安全、协作礼仪。措辞为本项目原创。
|
|
7
7
|
*/
|
|
8
8
|
export function buildSystemPrompt(ctx) {
|
|
9
|
-
const product = ctx.productName ?? "
|
|
9
|
+
const product = ctx.productName ?? "nowcrew";
|
|
10
10
|
return `你是 "${ctx.handle}",${product}(一个让人类与 AI agent 协作的共享工作区)中的 AI 成员。${product} 为可能运行在不同机器上的人与 agent 提供共享的消息服务。
|
|
11
11
|
|
|
12
12
|
## 你是谁
|
|
@@ -82,6 +82,7 @@ CRITICAL 规则:
|
|
|
82
82
|
|
|
83
83
|
## 沟通风格
|
|
84
84
|
用户看不到你的内部推理,所以:收到任务先确认并简述计划;多步工作发简短进度("正在做 2/3…");完成后总结结果。每条一两句,别刷屏。
|
|
85
|
+
- 完成汇报要直接说“已在当前线程汇报”或“已在任务线程汇报”,并说明“task #N 已置为 in_review”等事实。不要写“通过 ${product} 线程汇报”这类产品名+线程的生硬说法。
|
|
85
86
|
|
|
86
87
|
## Workspace 与分层记忆(CRITICAL — 索引+按需,配合上面的并行规则)
|
|
87
88
|
你的持久记忆在 \`$CREW_HOME\`(跨你所有任务共享),分三层:
|
package/dist/runner.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawnKimi } from "./runtimes/kimi.js";
|
|
|
13
13
|
import { normalizeEvent, parseLine, extractRunMeta } from "./normalize.js";
|
|
14
14
|
import { readSession, writeSession, pickResumeId } from "./session.js";
|
|
15
15
|
import { toConsoleLines } from "./console.js";
|
|
16
|
+
import { dslog } from "./slog.js";
|
|
16
17
|
// 注入提示词的 MEMORY.md 上限:只喂索引/角色,避免把膨胀的记忆全塞进上下文。
|
|
17
18
|
const MEMORY_INJECT_CAP = 6000;
|
|
18
19
|
const ICON = {
|
|
@@ -45,9 +46,22 @@ onConsole = () => { }) {
|
|
|
45
46
|
// 只决定「是否续用」:既有会话(sessionResume)且仍在缓存窗口内 → --resume;否则冷启动。
|
|
46
47
|
const resuming = supportsNativeResume && ws.sessionResume && pickResumeId(prior, Date.now(), config.resumeWarmMs, currentModel) != null;
|
|
47
48
|
// 既有会话但本轮不续用 → 轮换出新 uuid 冷启动(避免 --session-id 撞已存在会话)。
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const rotated = !!(ws.agentSessionId && ws.sessionResume && !resuming);
|
|
50
|
+
const launchSessionId = rotated ? await rotateAgentSession(ws.runDir) : ws.agentSessionId;
|
|
51
|
+
// resume 决策是「会话为什么没续上/为什么重开」的直接证据,把判定依据全量留痕
|
|
52
|
+
dslog("session.resume_decision", resuming ? "续用上轮会话 (--resume)" : "冷启动新会话", {
|
|
53
|
+
run_id: input.runId, agent_handle: input.handle, channel_id: input.channelId,
|
|
54
|
+
task_key: input.taskKey, runtime, model: currentModel,
|
|
55
|
+
resuming, rotated, session_id: launchSessionId,
|
|
56
|
+
prior_session_id: prior?.sessionId ?? null,
|
|
57
|
+
prior_age_ms: prior ? Date.now() - prior.lastRunAt : null,
|
|
58
|
+
warm_ms: config.resumeWarmMs,
|
|
59
|
+
decision_reason: !supportsNativeResume ? "runtime_no_resume"
|
|
60
|
+
: !config.resume ? "resume_disabled"
|
|
61
|
+
: !ws.sessionResume ? "first_run"
|
|
62
|
+
: resuming ? "warm_resume"
|
|
63
|
+
: "warm_window_expired_or_model_changed",
|
|
64
|
+
});
|
|
51
65
|
const systemPrompt = buildSystemPrompt({
|
|
52
66
|
handle: input.handle,
|
|
53
67
|
channelId: input.channelId,
|
|
@@ -137,6 +151,12 @@ onConsole = () => { }) {
|
|
|
137
151
|
: (() => {
|
|
138
152
|
throw new Error(`unsupported runtime: ${runtime}`);
|
|
139
153
|
})();
|
|
154
|
+
// 记下 OS 进程号:排查「进程被谁杀的/是否 OOM」时可与系统日志对齐
|
|
155
|
+
dslog("run.spawned", `${runtime} 进程已拉起 (pid ${child.pid})`, {
|
|
156
|
+
run_id: input.runId, agent_handle: input.handle, channel_id: input.channelId,
|
|
157
|
+
task_key: input.taskKey, runtime, model: currentModel,
|
|
158
|
+
os_pid: child.pid ?? null, session_id: launchSessionId, resume: resuming,
|
|
159
|
+
});
|
|
140
160
|
// 4) 逐行解析 stdout → 归一化 → 回调;顺带抓 session_id(记 lastRunAt 供 warm-window)+ token usage(度量)
|
|
141
161
|
const activities = [];
|
|
142
162
|
// 身份是本轮下发的 launchSessionId;仍兜底采 claude 自报的 session_id(理应一致)。
|
|
@@ -222,7 +242,7 @@ onConsole = () => { }) {
|
|
|
222
242
|
process.stdout.write(`📊 tokens: in=${u.inputTokens} out=${u.outputTokens} cache_read=${u.cacheReadTokens} cache_create=${u.cacheCreationTokens}` +
|
|
223
243
|
`${u.costUsd != null ? ` cost=$${u.costUsd.toFixed(4)}` : ""} ${resuming ? "(resumed)" : "(fresh)"}\n`);
|
|
224
244
|
}
|
|
225
|
-
return { exitCode, activities, model: observedModel, runtime, resumed: resuming, ...(usage ? { usage } : {}) };
|
|
245
|
+
return { exitCode, activities, model: observedModel, runtime, resumed: resuming, sessionId, ...(usage ? { usage } : {}) };
|
|
226
246
|
}
|
|
227
247
|
function defaultPrint(a) {
|
|
228
248
|
const icon = ICON[a.kind] ?? "·";
|
package/dist/serve.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { WebSocket } from "ws";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { initSlog, dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
|
|
7
9
|
import { runAgent } from "./runner.js";
|
|
8
10
|
import { collectMachineHello } from "./machine-info.js";
|
|
9
11
|
import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
|
|
@@ -23,6 +25,9 @@ export function serve(config, opts = {}) {
|
|
|
23
25
|
let ws = null;
|
|
24
26
|
let backoff = 1000;
|
|
25
27
|
const maxBackoff = opts.maxBackoffMs ?? 30_000;
|
|
28
|
+
let connectedAt = 0; // 本次 WS 连接建立时刻(断开日志算在线时长用)
|
|
29
|
+
initSlog(config.serverUrl, config.machineToken);
|
|
30
|
+
dslog("daemon.start", "daemon 常驻模式启动", { server_url: config.serverUrl, runtime: config.runtimeBin });
|
|
26
31
|
// 并行调度:同一 agent 可并行处理多个【不同任务】(线程/频道),每任务隔离 cwd+work-log。
|
|
27
32
|
// - running:正在跑的「agent:任务」去重键(同一任务重复唤醒才跳过)。
|
|
28
33
|
// - agentSlots:每 agent 当前并行数;超过 MAX_PARALLEL 的进 FIFO 队列(不丢)。
|
|
@@ -56,7 +61,11 @@ export function serve(config, opts = {}) {
|
|
|
56
61
|
ws = new WebSocket(wsUrl);
|
|
57
62
|
ws.on("open", () => {
|
|
58
63
|
backoff = 1000;
|
|
64
|
+
connectedAt = Date.now();
|
|
59
65
|
log(`🔌 已连接控制面 ${config.serverUrl}`);
|
|
66
|
+
dslog("daemon.ws_open", "已连接控制面", { server_url: config.serverUrl });
|
|
67
|
+
// 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
|
|
68
|
+
void drainSpool();
|
|
60
69
|
// 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
|
|
61
70
|
void collectMachineHello(config.agentsRoot)
|
|
62
71
|
.then((hello) => {
|
|
@@ -81,10 +90,17 @@ export function serve(config, opts = {}) {
|
|
|
81
90
|
// 库已重置)。不能静默丢弃这帧——否则只表现为神秘的「每 1s 重连」循环。打印可执行
|
|
82
91
|
// 提示,并把退避拉满,避免无意义高频重连刷屏 server(凭证失配不会靠重试自愈,
|
|
83
92
|
// 需在 NowCrew 重新 Add Computer 拿新连接命令)。
|
|
93
|
+
if (msg.type === "ready") {
|
|
94
|
+
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
95
|
+
const r = msg;
|
|
96
|
+
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
84
99
|
if (msg.type === "error") {
|
|
85
100
|
if (msg.code === "UNAUTHENTICATED") {
|
|
86
101
|
log(`🛑 控制面拒绝鉴权:机器凭证无效或已吊销 (UNAUTHENTICATED)。`);
|
|
87
102
|
log(` 请在 NowCrew 重新 "Add Computer" 获取新的连接命令,再到本机重跑(当前 --api-key 已失效)。`);
|
|
103
|
+
dslog("daemon.ws_auth_rejected", "控制面拒绝鉴权:机器凭证无效或已吊销", { level: "ERROR" });
|
|
88
104
|
backoff = maxBackoff; // 退避到最大,停止每秒重连刷屏
|
|
89
105
|
}
|
|
90
106
|
return;
|
|
@@ -140,16 +156,31 @@ export function serve(config, opts = {}) {
|
|
|
140
156
|
const threadId = msg.wake?.threadId;
|
|
141
157
|
const taskKey = threadId ?? msg.channelId;
|
|
142
158
|
const key = `${msg.agentHandle}:${taskKey}`;
|
|
159
|
+
// run_id 贯穿本轮全链路(wake→start→resume 决策→end),SLS 按 run_id 一查即得单轮时间线
|
|
160
|
+
const runId = randomUUID();
|
|
161
|
+
const runKeys = {
|
|
162
|
+
run_id: runId, agent_handle: msg.agentHandle, channel_id: msg.channelId,
|
|
163
|
+
thread_id: threadId ?? null, task_key: taskKey,
|
|
164
|
+
};
|
|
165
|
+
dslog("run.wake_received", `收到唤醒 ${msg.agentHandle}`, {
|
|
166
|
+
...runKeys, reason: msg.reason ?? "", sender: msg.wake?.senderHandle,
|
|
167
|
+
content_preview: (msg.wake?.content ?? "").replace(/\s+/g, " ").slice(0, 120),
|
|
168
|
+
});
|
|
143
169
|
if (running.has(key)) {
|
|
144
170
|
log(`↩︎ 跳过(该任务已在运行): ${key}`);
|
|
171
|
+
dslog("run.dedupe_skip", "跳过唤醒:该任务已在运行", { level: "WARN", ...runKeys });
|
|
145
172
|
return;
|
|
146
173
|
}
|
|
147
174
|
running.add(key);
|
|
148
175
|
// 并行槽:同 agent 超过 MAX_PARALLEL 个任务时在此排队(不丢),有空位再跑。
|
|
176
|
+
const slotWaitStart = Date.now();
|
|
149
177
|
await acquireSlot(msg.agentHandle);
|
|
178
|
+
const queueMs = Date.now() - slotWaitStart;
|
|
150
179
|
const threadLabel = threadId ?? null;
|
|
151
180
|
const from = msg.wake?.senderHandle ?? "?";
|
|
152
181
|
const incoming = msg.wake?.content ?? "";
|
|
182
|
+
dslog("run.start", `开始运行 ${msg.agentHandle}`, { ...runKeys, queue_ms: queueMs });
|
|
183
|
+
const runStartedAt = Date.now();
|
|
153
184
|
log(`\n${"─".repeat(56)}`);
|
|
154
185
|
log(`🔔 唤醒 agent=${msg.agentHandle} reason=${msg.reason ?? "?"}`);
|
|
155
186
|
log(` channel = ${msg.channelId}`);
|
|
@@ -226,6 +257,7 @@ export function serve(config, opts = {}) {
|
|
|
226
257
|
handle: msg.agentHandle,
|
|
227
258
|
channelId: msg.channelId,
|
|
228
259
|
taskKey, // 每任务隔离 cwd + work-log(并行不冲突)
|
|
260
|
+
runId, // 贯穿 SLS 日志的单轮关联键
|
|
229
261
|
// 唤醒锚点是具体消息(非纯频道唤醒)时,把它透传下去,供 `crew task create` 锚定到该消息。
|
|
230
262
|
...(threadId ? { wakeMessageId: threadId } : {}),
|
|
231
263
|
...(msg.wake?.content ? { wake: `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 ${readCmd} 读${threadId ? "本线程" : "频道"}后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}` } : {}),
|
|
@@ -252,16 +284,39 @@ export function serve(config, opts = {}) {
|
|
|
252
284
|
}
|
|
253
285
|
catch { /* ws 非 OPEN,忽略(用量非关键路径,丢一轮不阻塞) */ }
|
|
254
286
|
}
|
|
287
|
+
// run.end 是排查「任务没跑完就本轮结束」的核心证据:退出码 + 时长 + 最后活动 +
|
|
288
|
+
// 是否 resume + 用量。exit_code!=0 或时长异常短都值得追。
|
|
289
|
+
const lastActivity = result.activities.length
|
|
290
|
+
? result.activities[result.activities.length - 1].kind
|
|
291
|
+
: null;
|
|
292
|
+
dslog("run.end", `本轮结束 ${msg.agentHandle} (exit=${result.exitCode})`, {
|
|
293
|
+
...runKeys,
|
|
294
|
+
level: result.exitCode === 0 ? "INFO" : "ERROR",
|
|
295
|
+
exit_code: result.exitCode, duration_ms: Date.now() - runStartedAt,
|
|
296
|
+
runtime: result.runtime, model: result.model, resumed: result.resumed,
|
|
297
|
+
session_id: result.sessionId,
|
|
298
|
+
activity_count: result.activities.length, last_activity: lastActivity,
|
|
299
|
+
...(result.usage ? {
|
|
300
|
+
tokens_input: result.usage.inputTokens, tokens_output: result.usage.outputTokens,
|
|
301
|
+
cache_read: result.usage.cacheReadTokens, cache_creation: result.usage.cacheCreationTokens,
|
|
302
|
+
...(result.usage.costUsd != null ? { cost_usd: result.usage.costUsd } : {}),
|
|
303
|
+
} : {}),
|
|
304
|
+
});
|
|
255
305
|
reportActivity({ kind: "done", label: "本轮结束" });
|
|
256
306
|
reportConsole({ stream: "result", text: "● 本轮结束" });
|
|
257
307
|
log(`✅ agent=${msg.agentHandle} 本轮完成`);
|
|
258
308
|
}
|
|
259
309
|
catch (e) {
|
|
260
310
|
log(`❌ runAgent 失败: ${e.message}`);
|
|
311
|
+
dslog("run.error", `runAgent 失败: ${e.message}`, {
|
|
312
|
+
level: "ERROR", ...runKeys, duration_ms: Date.now() - runStartedAt,
|
|
313
|
+
error_message: e.message, error_stack: e.stack,
|
|
314
|
+
});
|
|
261
315
|
}
|
|
262
316
|
finally {
|
|
263
317
|
running.delete(key);
|
|
264
318
|
releaseSlot(msg.agentHandle);
|
|
319
|
+
void flushSlog(); // 每轮收尾冲一次,保证 run.end 尽快可查
|
|
265
320
|
}
|
|
266
321
|
});
|
|
267
322
|
ws.on("close", (code) => {
|
|
@@ -275,6 +330,14 @@ export function serve(config, opts = {}) {
|
|
|
275
330
|
log(` 请在 NowCrew 重新 "Add Computer" 获取新连接命令再重跑(当前 --api-key 已失效)。`);
|
|
276
331
|
}
|
|
277
332
|
log(`🔁 控制面断开,${Math.round(backoff / 1000)}s 后重连`);
|
|
333
|
+
// 此刻 server 大概率不可达 → 这条会落 spool,重连 drainSpool 时补传;
|
|
334
|
+
// ts 是现在(断开时刻),排查「daemon 为什么断」以它对齐 server 侧 machine_disconnected。
|
|
335
|
+
dslog("daemon.ws_close", `控制面断开 (code ${code})`, {
|
|
336
|
+
level: "WARN", close_code: code, backoff_ms: backoff,
|
|
337
|
+
online_ms: connectedAt ? Date.now() - connectedAt : null,
|
|
338
|
+
running_tasks: [...running].join(","),
|
|
339
|
+
});
|
|
340
|
+
void flushSlog();
|
|
278
341
|
setTimeout(connect, backoff);
|
|
279
342
|
backoff = Math.min(backoff * 2, maxBackoff);
|
|
280
343
|
});
|
package/dist/slog.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon 侧 SLS 日志客户端 —— 透过 server 的 POST /ingest/logs 上报(daemon 不直连 SLS)。
|
|
3
|
+
*
|
|
4
|
+
* 断线补传(关键设计):
|
|
5
|
+
* - 每条日志的 ts = 事件**实际发生时间**(epoch ms)。server 把它写成 SLS __time__,
|
|
6
|
+
* 所以补传的日志仍落在正确时间点,时间线不乱;补传条目带 spooled=true + reported_at 归因。
|
|
7
|
+
* - 上报失败(server 不可达/断网)→ 落本地 spool(~/.crew/logs/sls-spool/*.jsonl);
|
|
8
|
+
* 下次 WS 重连成功(drainSpool)时补传。「daemon 为什么断开」的现场就靠这批日志还原。
|
|
9
|
+
* - 进程退出(SIGINT/SIGTERM/exit)→ 残余队列同步落 spool,下次启动补传。
|
|
10
|
+
*
|
|
11
|
+
* 永不抛错、永不阻塞业务;未 init 时所有调用是 no-op(兼容单测/一次性 run 模式无凭证场景)。
|
|
12
|
+
*/
|
|
13
|
+
import { hostname } from "node:os";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
17
|
+
const FLUSH_INTERVAL_MS = 1500;
|
|
18
|
+
const BATCH_SIZE = 50;
|
|
19
|
+
const SPOOL_MAX_FILES = 50; // spool 目录文件数上限,超出丢最旧(日志非关键数据,防无限膨胀)
|
|
20
|
+
const SPOOL_DRAIN_CAP = 1000; // 单次补传条数上限
|
|
21
|
+
let cfg = null;
|
|
22
|
+
let defaults = {};
|
|
23
|
+
let queue = [];
|
|
24
|
+
let timer = null;
|
|
25
|
+
let flushing = false;
|
|
26
|
+
let lastErrorAt = 0;
|
|
27
|
+
function spoolDir() {
|
|
28
|
+
return process.env.CREW_SLS_SPOOL_DIR ?? join(homedir(), ".crew", "logs", "sls-spool");
|
|
29
|
+
}
|
|
30
|
+
/** serve/run 启动时调用一次;disabled(CREW_SLS_LOG=off)则保持 no-op。 */
|
|
31
|
+
export function initSlog(serverUrl, machineToken) {
|
|
32
|
+
if (process.env.CREW_SLS_LOG === "off" || process.env.CREW_SLS_LOG === "0")
|
|
33
|
+
return;
|
|
34
|
+
cfg = { serverUrl: serverUrl.replace(/\/+$/, ""), token: machineToken };
|
|
35
|
+
defaults = { host: hostname(), pid: process.pid };
|
|
36
|
+
// 退出兜底:残余队列同步落 spool(exit 回调只能做同步工作,append 正合适)
|
|
37
|
+
process.once("exit", () => spoolRemainingSync());
|
|
38
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
39
|
+
process.once(sig, () => {
|
|
40
|
+
spoolRemainingSync();
|
|
41
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** 追加默认关联字段(如 ready 帧下发的 machine_id),之后每条日志自动带上。 */
|
|
46
|
+
export function setSlogDefaults(fields) {
|
|
47
|
+
defaults = { ...defaults, ...fields };
|
|
48
|
+
}
|
|
49
|
+
/** 记一条结构化日志(异步批量上报;失败自动落 spool)。 */
|
|
50
|
+
export function dslog(eventType, message, fields = {}) {
|
|
51
|
+
if (!cfg)
|
|
52
|
+
return;
|
|
53
|
+
const { level, ...rest } = fields;
|
|
54
|
+
queue.push({
|
|
55
|
+
ts: Date.now(),
|
|
56
|
+
level: level ?? (eventType.includes("error") || eventType.includes("failed") ? "ERROR" : "INFO"),
|
|
57
|
+
event_type: eventType,
|
|
58
|
+
message,
|
|
59
|
+
fields: compact({ ...defaults, ...rest }),
|
|
60
|
+
});
|
|
61
|
+
if (queue.length >= BATCH_SIZE) {
|
|
62
|
+
void flushSlog();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!timer) {
|
|
66
|
+
timer = setTimeout(() => void flushSlog(), FLUSH_INTERVAL_MS);
|
|
67
|
+
timer.unref?.();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** 把内存队列冲到 server;失败整批落 spool(保 ts 不丢现场)。 */
|
|
71
|
+
export async function flushSlog() {
|
|
72
|
+
if (timer) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
timer = null;
|
|
75
|
+
}
|
|
76
|
+
if (!cfg || flushing || queue.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
flushing = true;
|
|
79
|
+
const batch = queue;
|
|
80
|
+
queue = [];
|
|
81
|
+
try {
|
|
82
|
+
await post(batch);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
appendSpool(batch);
|
|
86
|
+
warnThrottled(`SLS 日志上报失败,已落本地 spool(${batch.length} 条): ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
flushing = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 补传本地 spool(WS 重连成功后调用):上次断连/退出前落盘的现场日志,
|
|
94
|
+
* 此刻才有机会送出。条目加 spooled=true,ts 仍是当时的发生时间。
|
|
95
|
+
*/
|
|
96
|
+
export async function drainSpool() {
|
|
97
|
+
if (!cfg)
|
|
98
|
+
return;
|
|
99
|
+
const dir = spoolDir();
|
|
100
|
+
let files;
|
|
101
|
+
try {
|
|
102
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return; // 目录不存在 = 无积压
|
|
106
|
+
}
|
|
107
|
+
if (files.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
const entries = [];
|
|
110
|
+
const consumed = [];
|
|
111
|
+
for (const f of files) {
|
|
112
|
+
if (entries.length >= SPOOL_DRAIN_CAP)
|
|
113
|
+
break;
|
|
114
|
+
const path = join(dir, f);
|
|
115
|
+
try {
|
|
116
|
+
const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
try {
|
|
119
|
+
const e = JSON.parse(line);
|
|
120
|
+
if (typeof e.ts === "number" && typeof e.event_type === "string") {
|
|
121
|
+
entries.push({ ...e, fields: { ...(e.fields ?? {}), spooled: true } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { /* 坏行跳过 */ }
|
|
125
|
+
}
|
|
126
|
+
consumed.push(path);
|
|
127
|
+
}
|
|
128
|
+
catch { /* 读失败跳过该文件 */ }
|
|
129
|
+
}
|
|
130
|
+
if (entries.length === 0) {
|
|
131
|
+
for (const p of consumed)
|
|
132
|
+
try {
|
|
133
|
+
unlinkSync(p);
|
|
134
|
+
}
|
|
135
|
+
catch { /* 忽略 */ }
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
for (let i = 0; i < entries.length; i += 100) {
|
|
140
|
+
await post(entries.slice(i, i + 100));
|
|
141
|
+
}
|
|
142
|
+
for (const p of consumed)
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(p);
|
|
145
|
+
}
|
|
146
|
+
catch { /* 忽略 */ }
|
|
147
|
+
process.stdout.write(`📮 已补传离线期间的 ${entries.length} 条 SLS 日志\n`);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
warnThrottled(`spool 补传失败,留待下次重连: ${e.message}`); // 文件保留,下次再试
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ── 内部 ─────────────────────────────────────────────────────────
|
|
154
|
+
async function post(entries) {
|
|
155
|
+
const res = await fetch(`${cfg.serverUrl}/ingest/logs`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
entries: entries.map((e) => ({
|
|
160
|
+
ts: e.ts, level: e.level, event_type: e.event_type, message: e.message, fields: e.fields,
|
|
161
|
+
})),
|
|
162
|
+
}),
|
|
163
|
+
signal: AbortSignal.timeout(8000),
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
throw new Error(`HTTP ${res.status}`);
|
|
167
|
+
}
|
|
168
|
+
function appendSpool(entries) {
|
|
169
|
+
try {
|
|
170
|
+
const dir = spoolDir();
|
|
171
|
+
if (!existsSync(dir))
|
|
172
|
+
mkdirSync(dir, { recursive: true });
|
|
173
|
+
rotateSpool(dir);
|
|
174
|
+
const file = join(dir, `daemon-${Date.now()}-${process.pid}.jsonl`);
|
|
175
|
+
appendFileSync(file, entries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
|
|
176
|
+
}
|
|
177
|
+
catch { /* spool 也失败(磁盘只读等):放弃这批,不能影响业务 */ }
|
|
178
|
+
}
|
|
179
|
+
/** 目录内文件超上限时删最旧,防止长期断网把磁盘写满。 */
|
|
180
|
+
function rotateSpool(dir) {
|
|
181
|
+
try {
|
|
182
|
+
const files = readdirSync(dir)
|
|
183
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
184
|
+
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
|
185
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
186
|
+
for (const { f } of files.slice(0, Math.max(0, files.length - SPOOL_MAX_FILES + 1))) {
|
|
187
|
+
unlinkSync(join(dir, f));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch { /* 忽略 */ }
|
|
191
|
+
}
|
|
192
|
+
function spoolRemainingSync() {
|
|
193
|
+
if (queue.length === 0)
|
|
194
|
+
return;
|
|
195
|
+
const batch = queue;
|
|
196
|
+
queue = [];
|
|
197
|
+
appendSpool(batch);
|
|
198
|
+
}
|
|
199
|
+
function warnThrottled(msg) {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
if (now - lastErrorAt > 60_000) {
|
|
202
|
+
lastErrorAt = now;
|
|
203
|
+
process.stderr.write(`[slog] ${msg}\n`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function compact(fields) {
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
209
|
+
if (v === undefined)
|
|
210
|
+
continue;
|
|
211
|
+
out[k] = typeof v === "string" && v.length > 4096 ? `${v.slice(0, 4096)}…(truncated)` : v;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowcrew/daemon",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"ws": "^8",
|
|
21
|
-
"@nowcrew/cli": "^0.3.
|
|
21
|
+
"@nowcrew/cli": "^0.3.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^22.0.0",
|