@sema-agent/server 6.2.0 → 6.2.1
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/bake-runner/runner.js +11 -0
- package/dist/boot/config-center.d.ts +2 -0
- package/dist/boot/config-center.js +10 -1
- package/dist/boot/shutdown.d.ts +10 -0
- package/dist/boot/shutdown.js +3 -1
- package/dist/http/idempotency.js +10 -1
- package/dist/http/routes/tasks.js +5 -0
- package/dist/main.js +1 -0
- package/dist/memory-sync-client.js +5 -1
- package/dist/plugins/background-shell-support.d.ts +4 -0
- package/dist/plugins/background-shell-support.js +14 -4
- package/dist/plugins/local-task-attachment-store.d.ts +1 -0
- package/dist/plugins/local-task-attachment-store.js +28 -24
- package/package.json +1 -1
|
@@ -191,6 +191,17 @@ export class BakeRunner {
|
|
|
191
191
|
enqueueIngest(dockerBuildTickFrame(nextOrd(ord), Math.floor(elapsed / 1000)));
|
|
192
192
|
}
|
|
193
193
|
})();
|
|
194
|
+
// #131-6:创建即挂接——rejection 若等到 stopSupervisor(child 退出后)才有人接,会在整个构建
|
|
195
|
+
// 时长内悬置,Node 默认 unhandled-rejections=throw 直接终结 runner 进程。挂接后 supervisor 腿
|
|
196
|
+
// 静默死掉的后果有界:lease 心跳停 → image-api reaper 兜底(与 heartbeat error continue 同口径)。
|
|
197
|
+
void supervisor.catch((err) => {
|
|
198
|
+
try {
|
|
199
|
+
this.o.log.warn("bake_supervisor_error", { bakeId: bake.bakeId, err: String(err) });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* 日志面故障不再外抛 */
|
|
203
|
+
}
|
|
204
|
+
});
|
|
194
205
|
const exit = await child.exited;
|
|
195
206
|
childExited = true;
|
|
196
207
|
this.stopSupervisor(supervisor);
|
|
@@ -42,6 +42,8 @@ export interface ConfigCenterRuntime {
|
|
|
42
42
|
runnerTierFrozen: boolean;
|
|
43
43
|
pricing: ReturnType<typeof buildPricing>;
|
|
44
44
|
}): void;
|
|
45
|
+
/** #131-2:停刷新环(hardShutdown 收尾链;幂等,未起环时 no-op)。 */
|
|
46
|
+
stopRefreshLoop(): void;
|
|
45
47
|
/** 晚绑取值:refresh 热应用会整个换引用(hook LLM / resolveSpec 每次现取)。 */
|
|
46
48
|
getKeyResolver(): ((model: ModelKeyRef) => Promise<{
|
|
47
49
|
apiKey: string;
|
|
@@ -485,6 +485,8 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
485
485
|
// 函数而非常量:`config.degrade` 今天由 env 独占(applyEffective 不写它),但若哪天中心接管这面,这里
|
|
486
486
|
// 自动跟着走,不会退化成 boot 期快照。车道关(默认)⇒ ctx 空 ⇒ 该片恒 null ⇒ 零行为变化。
|
|
487
487
|
const restartCtx = () => (config.degrade?.reactive ? { reactiveDegradeTo: config.degrade.to } : {});
|
|
488
|
+
// #131-2:60s 刷新环的句柄提到 runtime 闭包层——stopRefreshLoop(hardShutdown 收尾链)要能清它。
|
|
489
|
+
let ccTimer;
|
|
488
490
|
return {
|
|
489
491
|
providerKind: configProvider?.kind,
|
|
490
492
|
promptSource,
|
|
@@ -913,7 +915,7 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
913
915
|
refreshInFlight = false;
|
|
914
916
|
}
|
|
915
917
|
};
|
|
916
|
-
|
|
918
|
+
ccTimer = setInterval(() => void refreshTick(), 60_000);
|
|
917
919
|
ccTimer.unref?.();
|
|
918
920
|
// Boot-deferred continuation(二轮复审 F5 改形):到货结果按「迟到的 boot」处理,而不是转普通 tick——
|
|
919
921
|
// 普通 tick 的 restartReasons(undefined, r) 会把 prompts/skills 面全判为差异 → restart → 中心持续慢时
|
|
@@ -1020,6 +1022,13 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
1020
1022
|
void bootConfigPending.then((r) => (lkgBooted ? refreshTick(r) : deferredBootApply(r)), () => { });
|
|
1021
1023
|
}
|
|
1022
1024
|
},
|
|
1025
|
+
stopRefreshLoop() {
|
|
1026
|
+
// #131-2:hardShutdown 收尾链——停机中途不再热应用配置(幂等;env 部署没起环时是 no-op)。
|
|
1027
|
+
if (ccTimer !== undefined) {
|
|
1028
|
+
clearInterval(ccTimer);
|
|
1029
|
+
ccTimer = undefined;
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1023
1032
|
};
|
|
1024
1033
|
}
|
|
1025
1034
|
//# sourceMappingURL=config-center.js.map
|
package/dist/boot/shutdown.d.ts
CHANGED
|
@@ -44,6 +44,16 @@ export interface ShutdownCtx {
|
|
|
44
44
|
inflight?: () => number;
|
|
45
45
|
lastActivityAt?: () => number;
|
|
46
46
|
};
|
|
47
|
+
/** #131-2:store 活体探针(main 建)——不停的话 hardShutdown 后仍对正在关闭的池发探针,刷假
|
|
48
|
+
* store_probe_dead 告警(文件头契约 3 的同族漏网)。缺席 = 该部署形没建探针。 */
|
|
49
|
+
storeLiveProbe: {
|
|
50
|
+
stop(): void;
|
|
51
|
+
} | undefined;
|
|
52
|
+
/** #131-2:config 60s 刷新环(config-center startRefreshLoop)——不停的话停机中途还可能热应用
|
|
53
|
+
* 一份新配置。缺席 = 纯 env 部署没起环。 */
|
|
54
|
+
configCenter: {
|
|
55
|
+
stopRefreshLoop(): void;
|
|
56
|
+
} | undefined;
|
|
47
57
|
}
|
|
48
58
|
/** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
|
|
49
59
|
export declare function installShutdownHandlers(ctx: ShutdownCtx): void;
|
package/dist/boot/shutdown.js
CHANGED
|
@@ -17,13 +17,15 @@ import { Runner, defaultTaskRegistry } from "@sema-agent/core";
|
|
|
17
17
|
import { createSighupIdleHandler } from "../sighup-idle.js";
|
|
18
18
|
/** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
|
|
19
19
|
export function installShutdownHandlers(ctx) {
|
|
20
|
-
const { config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, } = ctx;
|
|
20
|
+
const { config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
|
|
21
21
|
let closing = false;
|
|
22
22
|
const hardShutdown = () => {
|
|
23
23
|
if (closing)
|
|
24
24
|
return;
|
|
25
25
|
closing = true;
|
|
26
26
|
clearInterval(reaper);
|
|
27
|
+
storeLiveProbe?.stop(); // #131-2:契约 3 同族——收尾期不再有探针 tick 打向正在关闭的池
|
|
28
|
+
configCenter?.stopRefreshLoop(); // #131-2:停机中途不再热应用配置
|
|
27
29
|
otelExporter?.stop();
|
|
28
30
|
breakerState?.stop();
|
|
29
31
|
if (costQuota && "stop" in costQuota)
|
package/dist/http/idempotency.js
CHANGED
|
@@ -43,7 +43,16 @@ export class IdempotencyCache {
|
|
|
43
43
|
// cacheable success for the TTL. Handlers also avert an unhandled-rejection on the cached copy; the returned
|
|
44
44
|
// promise is the same object the caller awaits, so errors still surface to the caller.
|
|
45
45
|
promise.then((value) => {
|
|
46
|
-
|
|
46
|
+
// #131-7:谓词抛=当不可缓存(重试重跑)。裸调时谓词一抛,这条**派生** promise 就成了无人接的
|
|
47
|
+
// rejection(调用方拿的是原 promise,接不到它),且条目滞留缓存——双错方向。
|
|
48
|
+
let cacheable = false;
|
|
49
|
+
try {
|
|
50
|
+
cacheable = shouldCache(value);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* fall through: cacheable=false */
|
|
54
|
+
}
|
|
55
|
+
if (!cacheable)
|
|
47
56
|
this.entries.delete(key);
|
|
48
57
|
}, () => this.entries.delete(key));
|
|
49
58
|
return promise;
|
|
@@ -45,6 +45,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
45
45
|
if (!res.writableEnded)
|
|
46
46
|
res.write(`event: heartbeat\ndata: {}\n\n`); // real frame (not an SSE comment) — a per-frame-parsing BFF drops comments, so downstream saw a zero-frame window; EventSource clients without a heartbeat listener ignore it (zero break)
|
|
47
47
|
}, 15_000);
|
|
48
|
+
hb.unref?.(); // #131-5①:同文件 durableHeartbeat 口径——停机时心跳不拖事件循环
|
|
49
|
+
// #131-5②:重试客户端断连即清——`cached` 可悬到原始流 settle(deadline 级时长),此前这个
|
|
50
|
+
// timer 一直空转到那时(写有 writableEnded 守卫,烧的是 tick 本身);finally 仍是兜底清。
|
|
51
|
+
res.on("close", () => clearInterval(hb));
|
|
48
52
|
try {
|
|
49
53
|
const resp = await cached;
|
|
50
54
|
if (!res.writableEnded)
|
|
@@ -160,6 +164,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
|
|
|
160
164
|
if (!res.writableEnded && !res.destroyed)
|
|
161
165
|
res.write(`event: heartbeat\ndata: {}\n\n`); // real frame (not an SSE comment) — a per-frame-parsing BFF drops comments, so downstream saw a zero-frame window; EventSource clients without a heartbeat listener ignore it (zero break)
|
|
162
166
|
}, 15_000);
|
|
167
|
+
hb.unref?.(); // #131-5①:同文件 durableHeartbeat 口径——停机时心跳不拖事件循环
|
|
163
168
|
// Wrap the whole stream in idemCache.run so the IN-FLIGHT promise is stored BEFORE work begins — a retry
|
|
164
169
|
// that arrives WHILE this stream is still running (relay timeout → re-send same key) is then caught by the
|
|
165
170
|
// peek above and replays this result instead of starting a second billable stream (council). `ranLive`
|
package/dist/main.js
CHANGED
|
@@ -999,6 +999,7 @@ async function main() {
|
|
|
999
999
|
installShutdownHandlers({
|
|
1000
1000
|
config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter,
|
|
1001
1001
|
runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
|
|
1002
|
+
storeLiveProbe, configCenter, // #131-2:两个漏网的进程级后台环进收尾链
|
|
1002
1003
|
});
|
|
1003
1004
|
}
|
|
1004
1005
|
void main().catch((err) => {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// ③ 串行化 + 节流:syncOnce 按 promise 链串行(两轮并发会在同一文件面上交错写);trigger =
|
|
11
11
|
// fire-and-forget,上一轮在飞(排队中/执行中)则跳过(设计:简单 inflight 布尔)。
|
|
12
12
|
// conflicts = logger.warn 逐条上报(不自动 ladder——败者铸 sibling 的解决动作留给 operator/后续单)。
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
13
14
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
14
15
|
import { dirname, join } from "node:path";
|
|
15
16
|
import { encodeScopeSegment, syncMemoryScope, } from "@sema-agent/core";
|
|
@@ -85,7 +86,10 @@ async function loadCursor(path, scope, peer, log) {
|
|
|
85
86
|
/** 写 cursor:tmp + rename 原子(半写的 cursor = 下轮坏 JSON 当首轮,重收敛而非错基线)。 */
|
|
86
87
|
async function saveCursor(path, cursor) {
|
|
87
88
|
await mkdir(dirname(path), { recursive: true });
|
|
88
|
-
|
|
89
|
+
// #131-3:staging 名带熵(pid+random)——共享 memoryRoot 的跨进程形(server + run-local 同 data 根)
|
|
90
|
+
// 用固定 `.tmp` 会互抢 staging(一侧 rename 走对方的 tmp ⇒ 对方 ENOENT / 写出对方字节);同仓其余
|
|
91
|
+
// 原子写(config-lkg / local-session-store / skills-mcp / workflow-completion-inbox)全带熵,此处对齐。
|
|
92
|
+
const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
89
93
|
await writeFile(tmp, JSON.stringify(cursor), "utf8");
|
|
90
94
|
await rename(tmp, path);
|
|
91
95
|
}
|
|
@@ -143,6 +143,10 @@ export declare class BackgroundShellManager<S> {
|
|
|
143
143
|
* all-or-nothing: put every operation that can throw BEFORE the first side effect, or clean up on the throw
|
|
144
144
|
* path itself. Today's three lane builders are entirely non-throwing (listener attach + object construction).
|
|
145
145
|
*/
|
|
146
|
+
/** #131-T0:两条注册腿共用的 BG 超时钳制。非有限值(NaN/±Infinity)当缺席回退 default——
|
|
147
|
+
* Math.max/min 对 NaN 全塌 NaN,setTimeout(NaN) 被 Node 折成 1ms = 后台 shell 秒杀
|
|
148
|
+
* (方向反转:想给超时变成即杀;同族判例 TASK_TIMEOUT_SEC / leader 旋钮非法值)。 */
|
|
149
|
+
private boundedBgTimeoutSec;
|
|
146
150
|
adoptSync(builder: (ctx: LaunchCtx) => S, timeoutSec?: number): {
|
|
147
151
|
shellId: BackgroundShellId;
|
|
148
152
|
} | undefined;
|
|
@@ -146,11 +146,22 @@ export class BackgroundShellManager {
|
|
|
146
146
|
* all-or-nothing: put every operation that can throw BEFORE the first side effect, or clean up on the throw
|
|
147
147
|
* path itself. Today's three lane builders are entirely non-throwing (listener attach + object construction).
|
|
148
148
|
*/
|
|
149
|
+
/** #131-T0:两条注册腿共用的 BG 超时钳制。非有限值(NaN/±Infinity)当缺席回退 default——
|
|
150
|
+
* Math.max/min 对 NaN 全塌 NaN,setTimeout(NaN) 被 Node 折成 1ms = 后台 shell 秒杀
|
|
151
|
+
* (方向反转:想给超时变成即杀;同族判例 TASK_TIMEOUT_SEC / leader 旋钮非法值)。 */
|
|
152
|
+
boundedBgTimeoutSec(timeoutSec) {
|
|
153
|
+
const wanted = timeoutSec !== undefined && Number.isFinite(timeoutSec) ? timeoutSec : undefined;
|
|
154
|
+
// caps 同座防御:default/max 由各 lane 从常数×cfg 推导(k8s 腿含 cfg.timeoutMs 除法),坏输入会把
|
|
155
|
+
// NaN 带进 caps——任何一格非有限即回退安全常数(30min/24h,方向:保「有界但不即杀」,§3.6 不破)。
|
|
156
|
+
const dflt = Number.isFinite(this.caps.defaultBgTimeoutSec) ? this.caps.defaultBgTimeoutSec : 1_800;
|
|
157
|
+
const max = Number.isFinite(this.caps.maxBgTimeoutSec) ? this.caps.maxBgTimeoutSec : 86_400;
|
|
158
|
+
// Bounded BG timeout — never unbounded (design/103 §3.6). `maxBgTimeoutSec` is the fail-closed ceiling.
|
|
159
|
+
return Math.min(Math.max(1, wanted ?? dflt), max);
|
|
160
|
+
}
|
|
149
161
|
adoptSync(builder, timeoutSec) {
|
|
150
162
|
if (this.liveCount() >= this.caps.maxConcurrent)
|
|
151
163
|
return undefined; // refused → exec keeps the foreground
|
|
152
|
-
|
|
153
|
-
const bgTimeoutSec = Math.min(Math.max(1, timeoutSec ?? this.caps.defaultBgTimeoutSec), this.caps.maxBgTimeoutSec);
|
|
164
|
+
const bgTimeoutSec = this.boundedBgTimeoutSec(timeoutSec);
|
|
154
165
|
const shellId = `bg_${++this.counter}_${randomUUID()}`; // opaque — never the provider pid (§3.8)
|
|
155
166
|
const applyTerminal = (e, failed, exitCode) => {
|
|
156
167
|
if (e.status !== "running")
|
|
@@ -198,8 +209,7 @@ export class BackgroundShellManager {
|
|
|
198
209
|
if (this.liveCount() >= this.caps.maxConcurrent) {
|
|
199
210
|
return fail(new BackgroundShellError("limit_exceeded", `Too many running background shells (max ${this.caps.maxConcurrent}); KillShell one first.`));
|
|
200
211
|
}
|
|
201
|
-
|
|
202
|
-
const bgTimeoutSec = Math.min(Math.max(1, timeoutSec ?? this.caps.defaultBgTimeoutSec), this.caps.maxBgTimeoutSec);
|
|
212
|
+
const bgTimeoutSec = this.boundedBgTimeoutSec(timeoutSec); // #131-T0:与 adoptSync 同座(NaN 当缺席)
|
|
203
213
|
// Opaque brand — NOT derived from the provider job/pid (design/103 §3.8 越权红线).
|
|
204
214
|
const shellId = `bg_${++this.counter}_${randomUUID()}`;
|
|
205
215
|
const applyTerminal = (e, failed, exitCode) => {
|
|
@@ -2,6 +2,7 @@ import type { TaskAttachmentStore, TaskAttachmentRecord } from "./task-attachmen
|
|
|
2
2
|
export declare class LocalTaskAttachmentStore implements TaskAttachmentStore {
|
|
3
3
|
private readonly dir;
|
|
4
4
|
private metas;
|
|
5
|
+
private hydration;
|
|
5
6
|
constructor(rootDir: string);
|
|
6
7
|
private hydrate;
|
|
7
8
|
put(rec: TaskAttachmentRecord & {
|
|
@@ -13,35 +13,39 @@ const ID_RE = /^[0-9a-f-]{16,64}$/i; // uuid 形(server 铸);水合时非此形
|
|
|
13
13
|
export class LocalTaskAttachmentStore {
|
|
14
14
|
dir;
|
|
15
15
|
metas;
|
|
16
|
+
hydration;
|
|
16
17
|
constructor(rootDir) {
|
|
17
18
|
this.dir = join(rootDir, "attachments");
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
20
|
+
hydrate() {
|
|
21
|
+
// #131-1:记「在飞的 promise」而非「结果」(同仓正确形=local-session-store.loaded)。记结果时,
|
|
22
|
+
// 同进程两条并发首触各自 readdir,晚到的空快照会覆盖已被 put() 写入的索引——盘上有档、内存答 null。
|
|
23
|
+
this.hydration ??= (async () => {
|
|
24
|
+
const m = new Map();
|
|
25
|
+
try {
|
|
26
|
+
for (const f of await fsp.readdir(this.dir)) {
|
|
27
|
+
if (!f.endsWith(".json"))
|
|
28
|
+
continue;
|
|
29
|
+
const id = f.slice(0, -5);
|
|
30
|
+
if (!ID_RE.test(id))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const meta = JSON.parse(await fsp.readFile(join(this.dir, f), "utf8"));
|
|
34
|
+
if (meta && meta.id === id)
|
|
35
|
+
m.set(id, meta);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* 半写/损坏 meta:跳过(bin 无 meta 即孤儿,reap 收) */
|
|
39
|
+
}
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
catch {
|
|
43
|
+
/* 目录不存在 = 空店 */
|
|
44
|
+
}
|
|
45
|
+
this.metas = m;
|
|
46
|
+
return m;
|
|
47
|
+
})();
|
|
48
|
+
return this.hydration;
|
|
45
49
|
}
|
|
46
50
|
async put(rec) {
|
|
47
51
|
const metas = await this.hydrate();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "6.2.
|
|
3
|
+
"version": "6.2.1",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|