@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.
Files changed (86) hide show
  1. package/MIGRATION.md +23 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +58 -4
  5. package/dist/approval-card.d.ts +35 -2
  6. package/dist/approval-card.js +19 -1
  7. package/dist/background-session-reap.d.ts +55 -0
  8. package/dist/background-session-reap.js +90 -0
  9. package/dist/boot/config-center.d.ts +16 -4
  10. package/dist/boot/config-center.js +44 -15
  11. package/dist/boot/exit-boundary.js +17 -0
  12. package/dist/boot/runner-deps.js +1 -0
  13. package/dist/boot/runtime-caps.js +3 -2
  14. package/dist/boot/session-faces.js +3 -4
  15. package/dist/boot/shutdown.d.ts +2 -1
  16. package/dist/boot/shutdown.js +33 -12
  17. package/dist/boot/stage-07-capability-layer.d.ts +1 -1
  18. package/dist/boot/stage-07-capability-layer.js +6 -4
  19. package/dist/boot/stage-08-reapers.d.ts +1 -1
  20. package/dist/boot/stage-09-leader.d.ts +1 -1
  21. package/dist/boot/stage-10-http-server.d.ts +1 -1
  22. package/dist/boot/stage-10-http-server.js +9 -3
  23. package/dist/capabilities/builtin-tools.d.ts +14 -1
  24. package/dist/capabilities/builtin-tools.js +3 -0
  25. package/dist/capabilities/scenarios.d.ts +1 -1
  26. package/dist/capabilities/scenarios.js +3 -3
  27. package/dist/center-credential-renewer.d.ts +76 -0
  28. package/dist/center-credential-renewer.js +359 -0
  29. package/dist/center-credential.d.ts +76 -22
  30. package/dist/center-credential.js +119 -43
  31. package/dist/config-catalog.js +9 -4
  32. package/dist/config-center/apply-effective.js +5 -12
  33. package/dist/config-center/center-request.d.ts +13 -0
  34. package/dist/config-center/center-request.js +23 -3
  35. package/dist/config-center/facade.d.ts +1 -1
  36. package/dist/config-center/http-client.js +2 -5
  37. package/dist/config-center/read-warnings.d.ts +19 -48
  38. package/dist/config-center/read-warnings.js +7 -49
  39. package/dist/config-center/restart-signal.d.ts +8 -11
  40. package/dist/config-center/types.d.ts +4 -2
  41. package/dist/config-provider.js +1 -48
  42. package/dist/config-types.d.ts +77 -5
  43. package/dist/config-types.js +5 -0
  44. package/dist/config.js +49 -13
  45. package/dist/device-store.d.ts +1 -1
  46. package/dist/fleet/fleet-bus.d.ts +30 -4
  47. package/dist/fleet/fleet-bus.js +20 -4
  48. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  49. package/dist/fleet/fleet-reconciler.js +109 -79
  50. package/dist/fleet-client.d.ts +21 -7
  51. package/dist/fleet-client.js +41 -12
  52. package/dist/fleet-lease.d.ts +1 -1
  53. package/dist/http/approval-sweeps.js +5 -20
  54. package/dist/http/dispatch.js +20 -14
  55. package/dist/http/readiness.d.ts +87 -0
  56. package/dist/http/readiness.js +54 -0
  57. package/dist/http/resume-legs.js +5 -4
  58. package/dist/http/route-table.d.ts +7 -0
  59. package/dist/http/route-table.js +6 -0
  60. package/dist/http/routes/a2a-serve.js +6 -14
  61. package/dist/http/routes/approvals-assistant.js +1 -0
  62. package/dist/http/routes/background.d.ts +80 -0
  63. package/dist/http/routes/background.js +94 -0
  64. package/dist/http/routes/capabilities.js +2 -0
  65. package/dist/http/server.d.ts +21 -8
  66. package/dist/http/server.js +2 -2
  67. package/dist/model-entry-refusal.d.ts +11 -10
  68. package/dist/model-entry-refusal.js +13 -10
  69. package/dist/observability/fail-open.d.ts +6 -2
  70. package/dist/observability/fail-open.js +6 -2
  71. package/dist/parked-decide.d.ts +11 -5
  72. package/dist/parked-decide.js +4 -1
  73. package/dist/parked-revive-surface.d.ts +47 -0
  74. package/dist/parked-revive-surface.js +20 -0
  75. package/dist/plugins/background-shell-support.d.ts +20 -1
  76. package/dist/plugins/background-shell-support.js +26 -0
  77. package/dist/plugins/host-platform.d.ts +11 -1
  78. package/dist/plugins/host-platform.js +11 -0
  79. package/dist/plugins/remote-env-host.d.ts +14 -1
  80. package/dist/plugins/remote-env-host.js +18 -5
  81. package/dist/tool-approval.d.ts +39 -1
  82. package/dist/tool-approval.js +37 -8
  83. package/dist/trace/core-keyset-guard.d.ts +3 -3
  84. package/dist/trace/engine-notice-wire.d.ts +1 -1
  85. package/dist/trace/engine-notice-wire.js +2 -0
  86. package/package.json +3 -3
@@ -33,10 +33,17 @@
33
33
  * 「**静默满一个对账周期**」(本腿订阅总线自记每行的最后帧时刻):还在出帧 = 活着的证据。
34
34
  * durable 行**在场**的那三格没有这个歧义(`getRun` 命中 = 它就是一条 run 行),故照矩阵即时处置。
35
35
  *
36
- * ## 子代行(§1)
37
- * 随父:父行退休/退场时**同扫**(与 `onTerminal` 的 removal 集同姿势)。子代**不读 durable** ——
38
- * bg 子代的 durable 真源在 core 侧 registry,server 不越界重判。无父可循的孤儿子行(父行已不在 bus)
39
- * 按「bus 内滞留超一个对账周期且无新帧」陈述级退场。
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 readRun = async (id) => {
105
+ const budgetedRead = async (read) => {
90
106
  tally.reads++;
91
107
  let t;
92
- const rec = await Promise.race([
93
- deps.runs.getRun(id),
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 kids = new Map();
112
- for (const r of snap.tasks) {
113
- if (r.parentId === undefined)
114
- continue;
115
- const bucket = kids.get(r.parentId);
116
- if (bucket)
117
- bucket.push(r);
118
- else
119
- kids.set(r.parentId, [r]);
120
- }
121
- const sweepChildren = (parentId) => {
122
- for (const c of kids.get(parentId) ?? []) {
123
- deps.bus.removeTask(c.id, { removeReason: "orphaned", ...(c.cycleSeq !== undefined ? { cycleSeq: c.cycleSeq } : {}) });
124
- lastFrameAt.delete(c.id);
125
- tally.childrenSwept++;
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
- sweepChildren(row.id);
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
- sweepChildren(row.id);
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
- if (row.parentId !== undefined)
175
- continue;
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
- tally.reads++;
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)
@@ -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 续;`ready` 今 = `!draining`(实现只读
8
- * `isDraining()`,announce 本身在 boot 之后才起;S-740 起与 /health 就绪同一只谓词 —— 车OC 6b 注释改真,实现归 S-740)。
9
- * - **draining 翻转即刻再 announce**(第一时间自摘流,比任何观测都快);退出前 DELETE(显式注销,幂等)。
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
- /** Live drain flag (drainState) — read at every announce. */
69
- isDraining: () => boolean;
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 `draining` flips (self-remove from routing FIRST). */
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>;
@@ -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 announceNow = async () => {
85
- const body = {
86
- instanceId: opts.instanceId,
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 (stopped)
161
+ if (stopping)
136
162
  return;
137
- stopped = true;
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 });
@@ -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
- if (deps.drainState?.draining) {
75
- return {
76
- status: 503,
77
- body: {
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);
@@ -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" && url !== "/health" && !url.startsWith("/metrics");
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.modelReady && !deps.modelReady() ? { ready: false, readyReason: "model roster pending (first effective-config pull)" } : {}),
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: { reasons: restart.reasons, version: restart.version, since: restart.since } } : {}),
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 (deps.drainState?.draining && req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
222
- res.setHeader("retry-after", "15");
223
- sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance", ...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}) });
224
- return;
225
- }
226
- if (deps.modelReady && !deps.modelReady() && req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
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)");