@sema-agent/server 7.101.0 → 7.102.0
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/MIGRATION.md +23 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +58 -4
- package/dist/approval-card.d.ts +35 -2
- package/dist/approval-card.js +19 -1
- package/dist/background-session-reap.d.ts +55 -0
- package/dist/background-session-reap.js +90 -0
- package/dist/boot/config-center.d.ts +16 -4
- package/dist/boot/config-center.js +44 -15
- package/dist/boot/exit-boundary.js +17 -0
- package/dist/boot/runner-deps.js +1 -0
- package/dist/boot/runtime-caps.js +3 -2
- package/dist/boot/session-faces.js +3 -4
- package/dist/boot/shutdown.d.ts +2 -1
- package/dist/boot/shutdown.js +33 -12
- package/dist/boot/stage-07-capability-layer.d.ts +1 -1
- package/dist/boot/stage-07-capability-layer.js +6 -4
- package/dist/boot/stage-08-reapers.d.ts +1 -1
- package/dist/boot/stage-09-leader.d.ts +1 -1
- package/dist/boot/stage-10-http-server.d.ts +1 -1
- package/dist/boot/stage-10-http-server.js +9 -3
- package/dist/capabilities/builtin-tools.d.ts +14 -1
- package/dist/capabilities/builtin-tools.js +3 -0
- package/dist/capabilities/scenarios.d.ts +1 -1
- package/dist/capabilities/scenarios.js +3 -3
- package/dist/center-credential-renewer.d.ts +76 -0
- package/dist/center-credential-renewer.js +359 -0
- package/dist/center-credential.d.ts +76 -22
- package/dist/center-credential.js +119 -43
- package/dist/config-catalog.js +9 -4
- package/dist/config-center/apply-effective.js +5 -12
- package/dist/config-center/center-request.d.ts +13 -0
- package/dist/config-center/center-request.js +23 -3
- package/dist/config-center/facade.d.ts +1 -1
- package/dist/config-center/http-client.js +2 -5
- package/dist/config-center/read-warnings.d.ts +19 -48
- package/dist/config-center/read-warnings.js +7 -49
- package/dist/config-center/restart-signal.d.ts +8 -11
- package/dist/config-center/types.d.ts +4 -2
- package/dist/config-provider.js +1 -48
- package/dist/config-types.d.ts +77 -5
- package/dist/config-types.js +5 -0
- package/dist/config.js +49 -13
- package/dist/device-store.d.ts +1 -1
- package/dist/fleet/fleet-bus.d.ts +30 -4
- package/dist/fleet/fleet-bus.js +20 -4
- package/dist/fleet/fleet-reconciler.d.ts +29 -6
- package/dist/fleet/fleet-reconciler.js +109 -79
- package/dist/fleet-client.d.ts +21 -7
- package/dist/fleet-client.js +41 -12
- package/dist/fleet-lease.d.ts +1 -1
- package/dist/http/approval-sweeps.js +5 -20
- package/dist/http/dispatch.js +20 -14
- package/dist/http/readiness.d.ts +87 -0
- package/dist/http/readiness.js +54 -0
- package/dist/http/resume-legs.js +5 -4
- package/dist/http/route-table.d.ts +7 -0
- package/dist/http/route-table.js +6 -0
- package/dist/http/routes/a2a-serve.js +6 -14
- package/dist/http/routes/approvals-assistant.js +1 -0
- package/dist/http/routes/background.d.ts +80 -0
- package/dist/http/routes/background.js +94 -0
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/server.d.ts +21 -8
- package/dist/http/server.js +2 -2
- package/dist/model-entry-refusal.d.ts +11 -10
- package/dist/model-entry-refusal.js +13 -10
- package/dist/observability/fail-open.d.ts +6 -2
- package/dist/observability/fail-open.js +6 -2
- package/dist/parked-decide.d.ts +11 -5
- package/dist/parked-decide.js +4 -1
- package/dist/parked-revive-surface.d.ts +47 -0
- package/dist/parked-revive-surface.js +20 -0
- package/dist/plugins/background-shell-support.d.ts +20 -1
- package/dist/plugins/background-shell-support.js +26 -0
- package/dist/plugins/host-platform.d.ts +11 -1
- package/dist/plugins/host-platform.js +11 -0
- package/dist/plugins/remote-env-host.d.ts +14 -1
- package/dist/plugins/remote-env-host.js +18 -5
- package/dist/tool-approval.d.ts +39 -1
- package/dist/tool-approval.js +37 -8
- package/dist/trace/core-keyset-guard.d.ts +3 -3
- package/dist/trace/engine-notice-wire.d.ts +1 -1
- package/dist/trace/engine-notice-wire.js +2 -0
- package/package.json +3 -3
|
@@ -33,10 +33,17 @@
|
|
|
33
33
|
* 「**静默满一个对账周期**」(本腿订阅总线自记每行的最后帧时刻):还在出帧 = 活着的证据。
|
|
34
34
|
* durable 行**在场**的那三格没有这个歧义(`getRun` 命中 = 它就是一条 run 行),故照矩阵即时处置。
|
|
35
35
|
*
|
|
36
|
-
* ## 子代行(§1)
|
|
37
|
-
*
|
|
38
|
-
* bg 子代的 durable 真源在 core 侧 registry,server
|
|
39
|
-
*
|
|
36
|
+
* ## 子代行(§1;🔴 S-751 起改为「活性由属主说了算」)
|
|
37
|
+
* 子代行**不读 durable run 店**(它不是 run 行)。它的活性属主有两只,都在本仓可读 —— 读它们**不是**越界重判,
|
|
38
|
+
* 是问属主(S-751 之前此处写「bg 子代的 durable 真源在 core 侧 registry,server 不越界重判」,于是判据里只剩
|
|
39
|
+
* 「静默」这一条,而 registry 的读半场 `RunnerDeps.backgroundAgentStore` 一直就在本仓):
|
|
40
|
+
* ① core bg registry(`BackgroundAgentStore` 行 `status` 非终态 = `running` / `parked`);
|
|
41
|
+
* ② 本副本审批面的待决卡(子代发起、没人答的 ask —— 子代卡在那儿,当然不出帧)。
|
|
42
|
+
* **一条规则**:无父可循(父从未在 / 父本周期刚退休或退场)∧ 静默满一个对账周期 ∧ 两只属主都不作保 ⇒
|
|
43
|
+
* 陈述级退场。父离场**不再无条件同扫**子代(那是报案的一半病:父轮结束、子代一次工具调用 ≥60s 或审批卡
|
|
44
|
+
* 60s 没人答 ⇒ 活子代被扫);父离场只让子代「无父可循」,子代与既有孤儿走**同一只**判据、同一个循环。
|
|
45
|
+
* 同一问也挂在「durable 无行」那一格上:父链接未解析的 `a*` 行与顶层行同形(见上方收窄段),run 账不认识它
|
|
46
|
+
* ≠ 它死了。另一半病(陈述级退场立了代际墓碑、吞掉活子代之后的同代帧)的修在 `fleet-bus.ts` `removeTask`。
|
|
40
47
|
*
|
|
41
48
|
* ## workflow 行(§1)
|
|
42
49
|
* 只做同款「durable 终局投影」([ref] 的 snapshot pull 腿 + reapers 的 workflow recover 腿已在场),
|
|
@@ -77,7 +84,7 @@
|
|
|
77
84
|
* 与 `fleet-terminal-window.ts` 同一论证:两侧边界都由语义定死(比周期短 = 幽灵行仍在面板上,比周期长 =
|
|
78
85
|
* 拿对账腿当历史列表),旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。
|
|
79
86
|
*/
|
|
80
|
-
import { type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core";
|
|
87
|
+
import { type BackgroundAgentRecord, type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core";
|
|
81
88
|
import type { Logger } from "../observability/logger.js";
|
|
82
89
|
import type { Metrics } from "../observability/metrics.js";
|
|
83
90
|
import { type RunRecord } from "../plugins/store-contracts.js";
|
|
@@ -111,6 +118,12 @@ export interface FleetReconcileRunPort {
|
|
|
111
118
|
export interface FleetReconcileWorkflowPort {
|
|
112
119
|
get(id: string): Promise<WorkflowRun | null | undefined>;
|
|
113
120
|
}
|
|
121
|
+
/** S-751:core bg registry 账(`BackgroundAgentStore`)的只读窄口 —— 与 {@link FleetReconcileRunPort} 同一条
|
|
122
|
+
* 单写者纪律(接口上没有写方法)。生产 = `RunnerDeps.backgroundAgentStore` **同实例**(结构上就是它的 `get`
|
|
123
|
+
* 一面);`null` = 账上没有这一行。 */
|
|
124
|
+
export interface FleetReconcileAgentPort {
|
|
125
|
+
get(handle: string, scope: string): Promise<Pick<BackgroundAgentRecord, "status"> | null>;
|
|
126
|
+
}
|
|
114
127
|
export interface FleetReconcilerDeps {
|
|
115
128
|
bus: FleetEventBus;
|
|
116
129
|
/**
|
|
@@ -130,6 +143,14 @@ export interface FleetReconcilerDeps {
|
|
|
130
143
|
subscribeRunTerminal?: (fn: (rec: RunTerminalRecord) => void) => () => void;
|
|
131
144
|
runs?: FleetReconcileRunPort;
|
|
132
145
|
workflows?: FleetReconcileWorkflowPort | WorkflowRunStore;
|
|
146
|
+
/** S-751:子代活性的属主①(bg registry 账)。缺席 = 没有 bg 店的部署(纯内存 dev),这一问问不了。 */
|
|
147
|
+
agents?: FleetReconcileAgentPort;
|
|
148
|
+
/**
|
|
149
|
+
* S-751:子代活性的属主②:这只子代此刻有没有**待决**审批卡。只问是非(与 {@link isLiveOnThisReplica} 同姿势,
|
|
150
|
+
* 不搬 Map)。入参是 ask 发起者域(子代自己的 core session id,= 卡上的 `originTaskId`),**不是**行 id ——
|
|
151
|
+
* 行 id → 该域的换算只在 `FleetEventBus.childOwnerKeys`。生产 = `ToolApprovalCoordinator.hasLivePendingFrom`。
|
|
152
|
+
*/
|
|
153
|
+
hasPendingAsk?: (originTaskId: string) => boolean;
|
|
133
154
|
/** 本副本 id(与 `RunRecord.instanceId` 同域)。只进日志/读数 —— 两条 `running` 臂的**处置相同**
|
|
134
155
|
* (都是陈述级退场),分不分得出 claim 属谁不改变行为,故它不是判据。 */
|
|
135
156
|
instanceId?: string;
|
|
@@ -150,8 +171,10 @@ export interface FleetReconcileTally {
|
|
|
150
171
|
retired: number;
|
|
151
172
|
/** 陈述级退场的行数(顶层 + 孤儿子行)。 */
|
|
152
173
|
orphaned: number;
|
|
153
|
-
/**
|
|
174
|
+
/** 父本周期刚离场、自己又判成孤儿而陈述级退场的子行数(S-751 前 = 随父无条件同扫的数)。 */
|
|
154
175
|
childrenSwept: number;
|
|
176
|
+
/** S-751:静默满一周期、无父可循(或 run 账不认识),但属主作保而**留行**的子行数。 */
|
|
177
|
+
childrenKeptAlive: number;
|
|
155
178
|
/** durable 终局 ⇒ 投影退休的 workflow 行数。 */
|
|
156
179
|
workflowsRetired: number;
|
|
157
180
|
/** 本周期实际发出的 durable 点读次数。 */
|
|
@@ -11,7 +11,7 @@ export const FLEET_RECONCILE_COOLDOWN_MS = 60_000;
|
|
|
11
11
|
export const FLEET_RECONCILE_WATCH_MAX = 200;
|
|
12
12
|
export const FLEET_RECONCILE_WATCH_TTL_MS = 30 * 60_000;
|
|
13
13
|
const emptyTally = () => ({
|
|
14
|
-
scanned: 0, skippedLive: 0, skippedParked: 0, retired: 0, orphaned: 0, childrenSwept: 0, workflowsRetired: 0, reads: 0, cooling: false, failOpen: false,
|
|
14
|
+
scanned: 0, skippedLive: 0, skippedParked: 0, retired: 0, orphaned: 0, childrenSwept: 0, childrenKeptAlive: 0, workflowsRetired: 0, reads: 0, cooling: false, failOpen: false,
|
|
15
15
|
});
|
|
16
16
|
function rotate(rows, cursor) {
|
|
17
17
|
if (cursor === undefined || rows.length === 0)
|
|
@@ -22,6 +22,22 @@ function rotate(rows, cursor) {
|
|
|
22
22
|
return [...rows.slice(at), ...rows.slice(0, at)];
|
|
23
23
|
}
|
|
24
24
|
const SILENT_FOR_A_CYCLE = FLEET_RECONCILE_INTERVAL_MS;
|
|
25
|
+
function bgAgentVouches(status) {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case "running":
|
|
28
|
+
case "parked":
|
|
29
|
+
return true;
|
|
30
|
+
case "completed":
|
|
31
|
+
case "failed":
|
|
32
|
+
case "killed":
|
|
33
|
+
return false;
|
|
34
|
+
default: {
|
|
35
|
+
const unhandled = status;
|
|
36
|
+
recordFailOpen("server.fleet.agent-status-unmapped", String(unhandled));
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
25
41
|
export function createFleetReconciler(deps) {
|
|
26
42
|
const now = deps.now ?? (() => Date.now());
|
|
27
43
|
const lastFrameAt = new Map();
|
|
@@ -86,11 +102,11 @@ export function createFleetReconciler(deps) {
|
|
|
86
102
|
coolingUntil = undefined;
|
|
87
103
|
const deadline = now() + FLEET_RECONCILE_READ_BUDGET_MS;
|
|
88
104
|
const budgetLeft = () => tally.reads < FLEET_RECONCILE_MAX_ROWS && now() < deadline;
|
|
89
|
-
const
|
|
105
|
+
const budgetedRead = async (read) => {
|
|
90
106
|
tally.reads++;
|
|
91
107
|
let t;
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
return await Promise.race([
|
|
109
|
+
read(),
|
|
94
110
|
new Promise((_r, reject) => {
|
|
95
111
|
t = setTimeout(() => reject(new ReadAborted(`fleet reconcile read exceeded ${FLEET_RECONCILE_READ_BUDGET_MS}ms`)), Math.max(0, deadline - now()));
|
|
96
112
|
if (typeof t.unref === "function")
|
|
@@ -100,30 +116,43 @@ export function createFleetReconciler(deps) {
|
|
|
100
116
|
if (t !== undefined)
|
|
101
117
|
clearTimeout(t);
|
|
102
118
|
});
|
|
103
|
-
return rec;
|
|
104
119
|
};
|
|
120
|
+
const readRun = (id) => budgetedRead(() => deps.runs.getRun(id));
|
|
105
121
|
const snap = deps.bus.snapshot();
|
|
106
122
|
for (const r of snap.tasks)
|
|
107
123
|
if (!lastFrameAt.has(r.id))
|
|
108
124
|
lastFrameAt.set(r.id, nowMs);
|
|
109
125
|
const idle = (id) => nowMs - (lastFrameAt.get(id) ?? nowMs) >= SILENT_FOR_A_CYCLE;
|
|
110
126
|
const present = new Set(snap.tasks.map((r) => r.id));
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
const parentsOnBus = new Set(present);
|
|
128
|
+
const stillSilent = (row) => {
|
|
129
|
+
const cur = deps.bus.taskRow(row.id);
|
|
130
|
+
return cur !== undefined && cur.cycleSeq === row.cycleSeq && idle(row.id);
|
|
131
|
+
};
|
|
132
|
+
const keptAlive = (row, why) => {
|
|
133
|
+
tally.childrenKeptAlive++;
|
|
134
|
+
deps.logger?.info("fleet_row_reconciled", { taskId: row.id, disposition: "child_kept_alive", why, instanceId: deps.instanceId ?? null });
|
|
135
|
+
};
|
|
136
|
+
const judgeSilentRow = async (row, exit) => {
|
|
137
|
+
const handle = deps.bus.childOwnerKeys(row).handle;
|
|
138
|
+
if (handle !== undefined && deps.agents !== undefined && row.scope !== undefined) {
|
|
139
|
+
if (!budgetLeft())
|
|
140
|
+
return "over_budget";
|
|
141
|
+
const scope = row.scope;
|
|
142
|
+
const rec = await budgetedRead(() => deps.agents.get(handle, scope));
|
|
143
|
+
if (rec !== null && bgAgentVouches(rec.status)) {
|
|
144
|
+
keptAlive(row, "bg_registry");
|
|
145
|
+
return "settled";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const hasPendingAsk = deps.hasPendingAsk;
|
|
149
|
+
if (hasPendingAsk !== undefined && deps.bus.childOwnerKeys(row).sessionIds.some((id) => hasPendingAsk(id))) {
|
|
150
|
+
keptAlive(row, "pending_ask");
|
|
151
|
+
return "settled";
|
|
126
152
|
}
|
|
153
|
+
if (stillSilent(row))
|
|
154
|
+
exit();
|
|
155
|
+
return "settled";
|
|
127
156
|
};
|
|
128
157
|
const retire = (row, rec) => {
|
|
129
158
|
deps.bus.publishTask({
|
|
@@ -132,14 +161,14 @@ export function createFleetReconciler(deps) {
|
|
|
132
161
|
retiredBy: "reconcile",
|
|
133
162
|
...fleetRunResiduals(rec.result),
|
|
134
163
|
});
|
|
135
|
-
|
|
164
|
+
parentsOnBus.delete(row.id);
|
|
136
165
|
deps.bus.removeTask(row.id, { ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
|
|
137
166
|
lastFrameAt.delete(row.id);
|
|
138
167
|
tally.retired++;
|
|
139
168
|
deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "retired" });
|
|
140
169
|
};
|
|
141
170
|
const orphanExit = (row, why) => {
|
|
142
|
-
|
|
171
|
+
parentsOnBus.delete(row.id);
|
|
143
172
|
deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
|
|
144
173
|
lastFrameAt.delete(row.id);
|
|
145
174
|
if (deps.runs && watch.size < FLEET_RECONCILE_WATCH_MAX)
|
|
@@ -148,6 +177,61 @@ export function createFleetReconciler(deps) {
|
|
|
148
177
|
deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned" });
|
|
149
178
|
deps.logger?.info("fleet_row_reconciled", { taskId: row.id, disposition: "orphaned", why, instanceId: deps.instanceId ?? null });
|
|
150
179
|
};
|
|
180
|
+
const childExit = (row, parentId) => {
|
|
181
|
+
deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
|
|
182
|
+
lastFrameAt.delete(row.id);
|
|
183
|
+
parentsOnBus.delete(row.id);
|
|
184
|
+
if (present.has(parentId)) {
|
|
185
|
+
tally.childrenSwept++;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
tally.orphaned++;
|
|
189
|
+
deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned_child" });
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const judgeTopLevel = async (row) => {
|
|
193
|
+
tally.scanned++;
|
|
194
|
+
if (deps.isLiveOnThisReplica(row.id)) {
|
|
195
|
+
tally.skippedLive++;
|
|
196
|
+
return "settled";
|
|
197
|
+
}
|
|
198
|
+
if (!deps.runs)
|
|
199
|
+
return "settled";
|
|
200
|
+
if (!budgetLeft())
|
|
201
|
+
return "over_budget";
|
|
202
|
+
const rec = await readRun(row.id);
|
|
203
|
+
if (deps.isLiveOnThisReplica(row.id)) {
|
|
204
|
+
tally.skippedLive++;
|
|
205
|
+
return "settled";
|
|
206
|
+
}
|
|
207
|
+
const nowRow = deps.bus.taskRow(row.id);
|
|
208
|
+
if (nowRow === undefined)
|
|
209
|
+
return "settled";
|
|
210
|
+
if (nowRow.cycleSeq !== row.cycleSeq)
|
|
211
|
+
return "settled";
|
|
212
|
+
if (rec === undefined) {
|
|
213
|
+
if (!idle(row.id))
|
|
214
|
+
return "settled";
|
|
215
|
+
return judgeSilentRow(row, () => orphanExit(row, "durable_row_absent"));
|
|
216
|
+
}
|
|
217
|
+
if (isTerminalRunStatus(rec.status)) {
|
|
218
|
+
retire(row, rec);
|
|
219
|
+
return "settled";
|
|
220
|
+
}
|
|
221
|
+
if (isParkedRunStatus(rec.status)) {
|
|
222
|
+
tally.skippedParked++;
|
|
223
|
+
return "settled";
|
|
224
|
+
}
|
|
225
|
+
orphanExit(row, runProvenElsewhere(rec, deps.instanceId) ? "durable_running_other_replica" : "durable_running_no_local_leg");
|
|
226
|
+
return "settled";
|
|
227
|
+
};
|
|
228
|
+
const judgeChild = async (row, parentId) => {
|
|
229
|
+
if (parentsOnBus.has(parentId))
|
|
230
|
+
return "settled";
|
|
231
|
+
if (!idle(row.id))
|
|
232
|
+
return "settled";
|
|
233
|
+
return judgeSilentRow(row, () => childExit(row, parentId));
|
|
234
|
+
};
|
|
151
235
|
try {
|
|
152
236
|
for (const [id, w] of [...watch]) {
|
|
153
237
|
if (nowMs - w.at > FLEET_RECONCILE_WATCH_TTL_MS) {
|
|
@@ -171,53 +255,11 @@ export function createFleetReconciler(deps) {
|
|
|
171
255
|
tally.retired++;
|
|
172
256
|
}
|
|
173
257
|
for (const row of rotate(snap.tasks, taskCursor)) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
tally.scanned++;
|
|
177
|
-
if (deps.isLiveOnThisReplica(row.id)) {
|
|
178
|
-
tally.skippedLive++;
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
if (!deps.runs)
|
|
182
|
-
continue;
|
|
183
|
-
if (!budgetLeft()) {
|
|
258
|
+
const step = row.parentId === undefined ? await judgeTopLevel(row) : await judgeChild(row, row.parentId);
|
|
259
|
+
if (step === "over_budget") {
|
|
184
260
|
taskCursor = row.id;
|
|
185
261
|
break;
|
|
186
262
|
}
|
|
187
|
-
const rec = await readRun(row.id);
|
|
188
|
-
if (deps.isLiveOnThisReplica(row.id)) {
|
|
189
|
-
tally.skippedLive++;
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
const nowRow = deps.bus.taskRow(row.id);
|
|
193
|
-
if (nowRow === undefined)
|
|
194
|
-
continue;
|
|
195
|
-
if (nowRow.cycleSeq !== row.cycleSeq)
|
|
196
|
-
continue;
|
|
197
|
-
if (rec === undefined) {
|
|
198
|
-
if (idle(row.id))
|
|
199
|
-
orphanExit(row, "durable_row_absent");
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
if (isTerminalRunStatus(rec.status)) {
|
|
203
|
-
retire(row, rec);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (isParkedRunStatus(rec.status)) {
|
|
207
|
-
tally.skippedParked++;
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
orphanExit(row, runProvenElsewhere(rec, deps.instanceId) ? "durable_running_other_replica" : "durable_running_no_local_leg");
|
|
211
|
-
}
|
|
212
|
-
for (const row of snap.tasks) {
|
|
213
|
-
if (row.parentId === undefined || present.has(row.parentId))
|
|
214
|
-
continue;
|
|
215
|
-
if (!idle(row.id))
|
|
216
|
-
continue;
|
|
217
|
-
deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
|
|
218
|
-
lastFrameAt.delete(row.id);
|
|
219
|
-
tally.orphaned++;
|
|
220
|
-
deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned_child" });
|
|
221
263
|
}
|
|
222
264
|
if (wfGet) {
|
|
223
265
|
for (const wf of rotate(snap.workflows, wfCursor)) {
|
|
@@ -227,19 +269,7 @@ export function createFleetReconciler(deps) {
|
|
|
227
269
|
wfCursor = wf.id;
|
|
228
270
|
break;
|
|
229
271
|
}
|
|
230
|
-
|
|
231
|
-
let wfTimer;
|
|
232
|
-
const run = await Promise.race([
|
|
233
|
-
wfGet(wf.id),
|
|
234
|
-
new Promise((_r, reject) => {
|
|
235
|
-
wfTimer = setTimeout(() => reject(new ReadAborted("fleet reconcile workflow read over budget")), Math.max(0, deadline - now()));
|
|
236
|
-
if (typeof wfTimer.unref === "function")
|
|
237
|
-
wfTimer.unref();
|
|
238
|
-
}),
|
|
239
|
-
]).finally(() => {
|
|
240
|
-
if (wfTimer !== undefined)
|
|
241
|
-
clearTimeout(wfTimer);
|
|
242
|
-
});
|
|
272
|
+
const run = await budgetedRead(() => wfGet(wf.id));
|
|
243
273
|
if (!run || !isTerminalWorkflowStatus(run.status))
|
|
244
274
|
continue;
|
|
245
275
|
if (wf.scope !== undefined && run.scope !== wf.scope)
|
package/dist/fleet-client.d.ts
CHANGED
|
@@ -4,16 +4,25 @@
|
|
|
4
4
|
* - **可选分层(D3)**:仅当 center 已配置(sema-registry lane 的 baseUrl+token+worker)∧ `FLEET_ADVERTISE_ADDRESS`
|
|
5
5
|
* 显式声明时才启动 —— 不配 center 的部署零行为变化。address 不自猜(k8s pod IP / docker alias / 127.0.0.1:port
|
|
6
6
|
* 只有部署环境自己知道,故用显式 env)。
|
|
7
|
-
* - **announce=注册=心跳同形状幂等 upsert**:boot 后 POST 一次 + 每 heartbeatMs
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
7
|
+
* - **announce=注册=心跳同形状幂等 upsert**:boot 后(listen 之后)POST 一次 + 每 heartbeatMs 续。
|
|
8
|
+
* `ready` / `draining` 两字段由**同一只**就绪谓词的同一次读铸(S-740:`http/readiness.ts` 的 `readinessOf`,
|
|
9
|
+
* 与 `/health.ready`、`GET /readyz`、计费提交门同源):`ready = verdict.ready`(= roster 已落 ∧ ¬draining),
|
|
10
|
+
* `draining = reasons ∋ "draining"`。修前 `ready = !isDraining()`,不含 roster ⇒ roster 未落时心跳说「就绪」
|
|
11
|
+
* 而计费提交在 503。
|
|
12
|
+
* - **`restart?`(S-741)**:在场 ⇔ 本副本必须重启才能应用配置 —— 取自 `/health.restart` **同一只**取值口(装配层接
|
|
13
|
+
* `deployment.restartState`,config-center 的 `restartState()`),同一个对象,形 = settings-schema `RestartSignal`;
|
|
14
|
+
* 缺席即键缺席(中心按行整体替换,不合并 —— 一条不带它的心跳就是「不再需要」)。
|
|
15
|
+
* - **自报状态翻转即刻再 announce**(第一时间自摘流 / 自挂流 / 报重启,比任何观测都快):draining 翻转由收尾链直调
|
|
16
|
+
* `announceNow`;roster 落地与 restart 信号变化由装配层把 config-center 的 `onSelfReportChange` 接到 `announceNow`
|
|
17
|
+
* (两处 = 两类输入各自的唯一写者,触发都是 `announceNow`)。退出前 DELETE(显式注销,幂等)。
|
|
10
18
|
* - **usage 批报**:tracer 的 `brain.call` 已带 ALS principal —— FleetUsageAccumulator 按 principal×model
|
|
11
19
|
* 累计,每窗口(默认 60s,半开 [windowStartMs, windowEndMs))POST /api/fleet/usage。幂等键
|
|
12
20
|
* (worker,instanceId,windowStartMs) 重发安全;**发送失败 → merge 回累计器**,并入下一窗(失败窗从未入账,
|
|
13
21
|
* 向前合并不会双计 —— 只延迟,不丢量)。
|
|
14
22
|
* - 全部 fire-and-forget + swallow-guarded:fleet 面是运维旁路,绝不影响任务面;网络错误只 warn 一次/连败类。
|
|
15
23
|
*/
|
|
16
|
-
import { UsageReport } from "@sema-agent/settings-schema/fleet";
|
|
24
|
+
import { UsageReport, type RestartSignal } from "@sema-agent/settings-schema/fleet";
|
|
25
|
+
import type { ReadinessVerdict } from "./http/readiness.js";
|
|
17
26
|
import { type CenterEndpoint } from "./config-center/center-request.js";
|
|
18
27
|
/** Bound on distinct principal×model keys per window — a runaway/multi-tenant burst collapses into
|
|
19
28
|
* `__other__` instead of growing unbounded (same posture as budget.ts cardinalityGuard). */
|
|
@@ -65,8 +74,12 @@ export interface FleetClientOpts {
|
|
|
65
74
|
/** Reachable base URL of THIS instance — the operator's explicit declaration (`FLEET_ADVERTISE_ADDRESS`). */
|
|
66
75
|
advertiseAddress: string;
|
|
67
76
|
version: string;
|
|
68
|
-
/**
|
|
69
|
-
|
|
77
|
+
/** S-740:就绪判(装配层接 `readinessOf(deployment)` —— HTTP 面读的同一个 deps 组)。每条公告现读**一次**,
|
|
78
|
+
* `ready` 与 `draining` 两字段都由这一次读铸(不会撕裂成 `ready:true` + `draining:true`)。 */
|
|
79
|
+
readiness: () => ReadinessVerdict;
|
|
80
|
+
/** S-741:restart 信号(装配层接 `/health` 同一只取值口 `deployment.restartState`)。每条公告现读一次;`undefined` ⇒ 公告不带
|
|
81
|
+
* `restart` 键。必填:心跳面没有「不知道要不要重启」这一形。 */
|
|
82
|
+
restart: () => RestartSignal | undefined;
|
|
70
83
|
usage?: FleetUsageAccumulator;
|
|
71
84
|
heartbeatMs?: number;
|
|
72
85
|
usageWindowMs?: number;
|
|
@@ -78,7 +91,8 @@ export interface FleetClientOpts {
|
|
|
78
91
|
fetchImpl?: typeof fetch;
|
|
79
92
|
}
|
|
80
93
|
export interface FleetClient {
|
|
81
|
-
/** Immediate out-of-cycle announce — call when
|
|
94
|
+
/** Immediate out-of-cycle announce — call when a self-reported input flips (draining: self-remove from routing FIRST;
|
|
95
|
+
* roster landed: become routable without waiting a heartbeat; restart signal changed: S-741). */
|
|
82
96
|
announceNow: () => Promise<void>;
|
|
83
97
|
/** Stop timers + best-effort DELETE deregistration (bounded; never throws). */
|
|
84
98
|
stop: () => Promise<void>;
|
package/dist/fleet-client.js
CHANGED
|
@@ -81,17 +81,22 @@ export function startFleetClient(opts) {
|
|
|
81
81
|
const send = (route, method, json) => centerFetch(opts.center, { kind: route }, { method, json, timeoutMs: FLEET_CLIENT_FETCH_TIMEOUT_MS, fetchImpl: f });
|
|
82
82
|
let stopped = false;
|
|
83
83
|
let failStreak = 0;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
worker: opts.center.worker,
|
|
88
|
-
address: opts.advertiseAddress,
|
|
89
|
-
version: opts.version,
|
|
90
|
-
ready: !opts.isDraining(),
|
|
91
|
-
draining: opts.isDraining(),
|
|
92
|
-
heartbeatMs,
|
|
93
|
-
};
|
|
84
|
+
const announceOnce = async () => {
|
|
85
|
+
if (stopped)
|
|
86
|
+
return;
|
|
94
87
|
try {
|
|
88
|
+
const verdict = opts.readiness();
|
|
89
|
+
const restart = opts.restart();
|
|
90
|
+
const body = {
|
|
91
|
+
instanceId: opts.instanceId,
|
|
92
|
+
worker: opts.center.worker,
|
|
93
|
+
address: opts.advertiseAddress,
|
|
94
|
+
version: opts.version,
|
|
95
|
+
ready: verdict.ready,
|
|
96
|
+
draining: !verdict.ready && verdict.reasons.includes("draining"),
|
|
97
|
+
heartbeatMs,
|
|
98
|
+
...(restart !== undefined ? { restart } : {}),
|
|
99
|
+
};
|
|
95
100
|
const { res } = await send("fleet-instances", "POST", body);
|
|
96
101
|
if (!res.ok)
|
|
97
102
|
throw new Error(`announce ${res.status}${await rejectionSnippet(res)}`);
|
|
@@ -105,6 +110,26 @@ export function startFleetClient(opts) {
|
|
|
105
110
|
opts.logger?.warn("fleet_announce_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
106
111
|
}
|
|
107
112
|
};
|
|
113
|
+
let inFlight;
|
|
114
|
+
let again = false;
|
|
115
|
+
const announceNow = () => {
|
|
116
|
+
if (inFlight !== undefined) {
|
|
117
|
+
again = true;
|
|
118
|
+
return inFlight;
|
|
119
|
+
}
|
|
120
|
+
inFlight = (async () => {
|
|
121
|
+
try {
|
|
122
|
+
do {
|
|
123
|
+
again = false;
|
|
124
|
+
await announceOnce();
|
|
125
|
+
} while (again && !stopped);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
inFlight = undefined;
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
return inFlight;
|
|
132
|
+
};
|
|
108
133
|
let windowStartMs = Date.now();
|
|
109
134
|
const flushUsage = async () => {
|
|
110
135
|
if (!opts.usage)
|
|
@@ -131,13 +156,17 @@ export function startFleetClient(opts) {
|
|
|
131
156
|
hb.unref?.();
|
|
132
157
|
const uw = opts.usage ? setInterval(() => void flushUsage(), usageWindowMs) : undefined;
|
|
133
158
|
uw?.unref?.();
|
|
159
|
+
let stopping = false;
|
|
134
160
|
const stop = async () => {
|
|
135
|
-
if (
|
|
161
|
+
if (stopping)
|
|
136
162
|
return;
|
|
137
|
-
|
|
163
|
+
stopping = true;
|
|
138
164
|
clearInterval(hb);
|
|
139
165
|
if (uw)
|
|
140
166
|
clearInterval(uw);
|
|
167
|
+
while (inFlight !== undefined)
|
|
168
|
+
await inFlight;
|
|
169
|
+
stopped = true;
|
|
141
170
|
await flushUsage();
|
|
142
171
|
try {
|
|
143
172
|
await send("fleet-instances", "DELETE", { instanceId: opts.instanceId, worker: opts.center.worker });
|
package/dist/fleet-lease.d.ts
CHANGED
|
@@ -116,7 +116,7 @@ export declare class FleetLeaseManager {
|
|
|
116
116
|
/**
|
|
117
117
|
* Env/config gate(与 startFleetClientFromEnv 同 D3 分层姿势,但 lease 不需要 FLEET_ADVERTISE_ADDRESS ——
|
|
118
118
|
* 它是出站消费面,不是入站可达性声明):sema-registry lane 配齐(baseUrl + **声明了**凭证 + worker)即启;任一缺失 →
|
|
119
|
-
* undefined,非 fleet 部署零行为变化。S-651:按**声明**装、不按内容装 —— `SEMA_REGISTRY_TOKEN_FILE`
|
|
119
|
+
* undefined,非 fleet 部署零行为变化。S-651:按**声明**装、不按内容装 —— `SEMA_REGISTRY_TOKEN_FILE`(`CENTER_CREDENTIAL_ENV.tokenFile`)在场而
|
|
120
120
|
* 文件此刻不可读,管理器照样装上(每次协商取值,失败走响亮的鉴权失败臂;文件恢复即恢复)。
|
|
121
121
|
*/
|
|
122
122
|
export declare function createFleetLeaseFromEnv(cfg: {
|
|
@@ -6,6 +6,7 @@ import { APPROVAL_GATE_KINDS } from "../tool-approval.js";
|
|
|
6
6
|
import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
|
|
7
7
|
import { hasServiceAuth } from "./dispatch.js";
|
|
8
8
|
import { gatedPrincipal } from "./principal-gate.js";
|
|
9
|
+
import { readinessOf, billableRefusalOf } from "./readiness.js";
|
|
9
10
|
export function createApprovalSweeps(ctx) {
|
|
10
11
|
const { deps, resumeCheckpoint, mayDiscloseRowError } = ctx;
|
|
11
12
|
const denySweepClockGuard = createWallClockJumpGuard({
|
|
@@ -71,26 +72,10 @@ export function createApprovalSweeps(ctx) {
|
|
|
71
72
|
if (!hasServiceAuth(deps.config) && !deps.config.allowUnauthedWrites) {
|
|
72
73
|
return { status: 503, body: { error: "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions", errorCode: "auth.service_token_required" } };
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
error: "draining",
|
|
79
|
-
errorCode: "draining",
|
|
80
|
-
message: "this instance is draining for shutdown/upgrade — retry against the replacement instance",
|
|
81
|
-
...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}),
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
if (deps.modelReady && !deps.modelReady()) {
|
|
86
|
-
return {
|
|
87
|
-
status: 503,
|
|
88
|
-
body: {
|
|
89
|
-
error: "model_roster_pending",
|
|
90
|
-
errorCode: "state.model_roster_pending",
|
|
91
|
-
message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly",
|
|
92
|
-
},
|
|
93
|
-
};
|
|
75
|
+
const readiness = readinessOf(deps);
|
|
76
|
+
if (!readiness.ready) {
|
|
77
|
+
const refusal = billableRefusalOf(readiness, deps.drainState?.reason);
|
|
78
|
+
return { status: refusal.status, body: refusal.body };
|
|
94
79
|
}
|
|
95
80
|
if (deps.costQuota && req.httpReq) {
|
|
96
81
|
const quotaPrincipal = gatedPrincipal(req.httpReq, deps.config);
|
package/dist/http/dispatch.js
CHANGED
|
@@ -3,8 +3,9 @@ import { setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isDestructiveSessio
|
|
|
3
3
|
import { publicStoreProbeError } from "../store-live-probe.js";
|
|
4
4
|
import { looksLikeJwt } from "@sema-agent/settings-schema/api/auth-bridge";
|
|
5
5
|
import { ROUTE_METHOD_ORDER } from "./route-ctx.js";
|
|
6
|
-
import { PRE_CREDENTIAL_ROUTES, POST_CREDENTIAL_ROUTES, routeLabel, isBillableSubmitPath, isCredentialGatedRewrite, isMethodDispatchedSubmitPath, lookupRoute } from "./route-table.js";
|
|
6
|
+
import { PRE_CREDENTIAL_ROUTES, POST_CREDENTIAL_ROUTES, routeLabel, isBillableSubmitPath, isCredentialGatedRewrite, isMethodDispatchedSubmitPath, lookupRoute, isProbePath } from "./route-table.js";
|
|
7
7
|
import { sendJson, sendError, isSseResponse } from "./send.js";
|
|
8
|
+
import { readinessOf, healthReadinessKeys, sendReadyz, billableRefusalOf, sendBillableRefusal } from "./readiness.js";
|
|
8
9
|
import { authorized, systemFor, gatedPrincipal, sendPrincipalRefusal } from "./principal-gate.js";
|
|
9
10
|
const PROCESS_STARTED_AT_MS = Date.now() - Math.round(process.uptime() * 1000);
|
|
10
11
|
export function hasServiceAuth(config) {
|
|
@@ -67,7 +68,7 @@ export function createDispatch(ctx) {
|
|
|
67
68
|
const search = qMark === -1 ? "" : rawUrl.slice(qMark);
|
|
68
69
|
const allow = { pre: [], post: [] };
|
|
69
70
|
noteActivity(url);
|
|
70
|
-
const admitted = method !== "OPTIONS" &&
|
|
71
|
+
const admitted = method !== "OPTIONS" && !isProbePath(url);
|
|
71
72
|
if (admitted)
|
|
72
73
|
counters.admittedInflight++;
|
|
73
74
|
if (admitted)
|
|
@@ -89,7 +90,7 @@ export function createDispatch(ctx) {
|
|
|
89
90
|
const status = aborted ? "499" : String(res.statusCode);
|
|
90
91
|
deps.metrics?.inc("http_requests_total", { route, method, status });
|
|
91
92
|
deps.metrics?.observe("http_request_duration_seconds", seconds, { route });
|
|
92
|
-
const log = route === "/health" || route === "/metrics" ? deps.logger?.debug : deps.logger?.info;
|
|
93
|
+
const log = route === "/health" || route === "/readyz" || route === "/metrics" ? deps.logger?.debug : deps.logger?.info;
|
|
93
94
|
log?.call(deps.logger, "request", { method, route, status, ms: Date.now() - startedAt, ...(reqState.runStatus !== undefined ? { runStatus: reqState.runStatus } : {}), ...(reqState.runErrorCode !== undefined ? { errorCode: reqState.runErrorCode } : {}), ...(aborted ? { aborted: true, ...(reqState.streamTaskId ? { taskId: reqState.streamTaskId } : {}), ...(reqState.streamDetached ? { detached: true } : {}) } : {}) });
|
|
94
95
|
};
|
|
95
96
|
res.on("finish", () => record(false));
|
|
@@ -112,7 +113,7 @@ export function createDispatch(ctx) {
|
|
|
112
113
|
...(deps.drainState?.draining ? { draining: true, ...(deps.drainState.since ? { drainingSince: deps.drainState.since } : {}), ...(deps.drainState.reason ? { drainReason: deps.drainState.reason } : {}) } : {}),
|
|
113
114
|
...(deps.drainState?.inflight ? { inflight: deps.drainState.inflight() } : {}),
|
|
114
115
|
...(deps.storeDegraded ? { durable: false, storeDegraded: true } : {}),
|
|
115
|
-
...(deps
|
|
116
|
+
...healthReadinessKeys(readinessOf(deps)),
|
|
116
117
|
model: deps.config.model.id,
|
|
117
118
|
sessionBackend: deps.sessionStoreLabel ?? deps.config.sessionBackend,
|
|
118
119
|
sessions: deps.runner.sessions.size,
|
|
@@ -123,7 +124,7 @@ export function createDispatch(ctx) {
|
|
|
123
124
|
startedAt: PROCESS_STARTED_AT_MS,
|
|
124
125
|
port: deps.config.port,
|
|
125
126
|
...(deps.config.directDoorActive ? { directDoorActive: true } : {}),
|
|
126
|
-
...(restart ? { restartRequired: true, restart
|
|
127
|
+
...(restart ? { restartRequired: true, restart } : {}),
|
|
127
128
|
...(() => {
|
|
128
129
|
const stuck = deps.planeDeferredState?.();
|
|
129
130
|
return stuck ? { modelPlaneDeferred: { version: stuck.version, since: stuck.since, noHandoff: true, ...(stuck.blocked ? { blockedReasons: stuck.blocked } : {}) } } : {};
|
|
@@ -140,6 +141,10 @@ export function createDispatch(ctx) {
|
|
|
140
141
|
...(gen.configApplyStaleMs !== undefined ? { configApplyStaleMs: gen.configApplyStaleMs } : {}),
|
|
141
142
|
};
|
|
142
143
|
})(),
|
|
144
|
+
...(() => {
|
|
145
|
+
const credential = deps.centerCredentialState?.();
|
|
146
|
+
return credential ? { centerCredential: credential } : {};
|
|
147
|
+
})(),
|
|
143
148
|
...(() => {
|
|
144
149
|
const probe = deps.storeLiveState?.();
|
|
145
150
|
if (!probe)
|
|
@@ -152,6 +157,10 @@ export function createDispatch(ctx) {
|
|
|
152
157
|
});
|
|
153
158
|
return;
|
|
154
159
|
}
|
|
160
|
+
if (req.method === "GET" && url === "/readyz") {
|
|
161
|
+
sendReadyz(res, readinessOf(deps));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
155
164
|
if (req.method === "GET" && (url === "/metrics" || url === "/metrics/summary" || url === "/metrics/plan-cache")) {
|
|
156
165
|
if (!deps.metrics) {
|
|
157
166
|
sendError(res, 404, "feature.metrics_disabled", "metrics disabled");
|
|
@@ -218,15 +227,12 @@ export function createDispatch(ctx) {
|
|
|
218
227
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions");
|
|
219
228
|
return;
|
|
220
229
|
}
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
res.setHeader("retry-after", "5");
|
|
228
|
-
sendError(res, 503, "state.model_roster_pending", "model_roster_pending", { message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly" });
|
|
229
|
-
return;
|
|
230
|
+
if (req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
|
|
231
|
+
const verdict = readinessOf(deps);
|
|
232
|
+
if (!verdict.ready) {
|
|
233
|
+
sendBillableRefusal(res, billableRefusalOf(verdict, deps.drainState?.reason));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
230
236
|
}
|
|
231
237
|
if (isDestructiveSessionWrite(req.method ?? "", url) && !anyServiceAuth && !deps.config.allowUnauthedWrites) {
|
|
232
238
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting destructive session writes (policy / sync-import / fork / delete)");
|