@sema-agent/server 2.0.0 → 2.0.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.
@@ -0,0 +1,81 @@
1
+ import { createLeaderEndpoint } from "../leader/endpoint.js";
2
+ import { createLeaderRunner } from "../leader/wire.js";
3
+ export function createLeaderFace(ctx) {
4
+ const { config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore } = ctx;
5
+ // v2 leader endpoint (design/50 + design/68): wire when LEADER_ENABLED + an isolated remote-exec backend
6
+ // (E2B or k8s/Kata — SSH/ADB are single-worker real-system backends, not fan-out targets). Default off →
7
+ // zero prod impact. 🔴 a real run also needs `git` on the host PATH + a durable remote with write creds.
8
+ // F2(a) factory mode (preferred) = the service's executionEnvFactory + MinIO self-upload diff-out; the k8s
9
+ // lane REQUIRES it (no static k8s mode). E2B without MinIO falls back to the static compat mode.
10
+ const leaderMinio = process.env.MINIO_ENDPOINT && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
11
+ ? {
12
+ s3: {
13
+ endpoint: process.env.MINIO_ENDPOINT,
14
+ bucket: process.env.MINIO_BUCKET ?? "workspaces",
15
+ accessKey: process.env.MINIO_ACCESS_KEY,
16
+ secretKey: process.env.MINIO_SECRET_KEY,
17
+ ...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
18
+ },
19
+ }
20
+ : {};
21
+ const leaderProvider = config.remoteExec?.provider;
22
+ // DUAL-MODE §5: orchestration is an ENGINE capability, not fleet-only — the TOC `host` lane runs leader fan-out
23
+ // bounded by ONE box (isolation=none, NON-durable: no snapshot, so the durable sub-worker suspend block below is
24
+ // skipped — host workers run to completion in-process-adjacent). e2b/k8s keep their isolated/suspendable posture.
25
+ const leaderEndpoint = config.leaderEnabled &&
26
+ (leaderProvider === "e2b" ||
27
+ (leaderProvider === "k8s" && "s3" in leaderMinio && executionEnvFactory) ||
28
+ (leaderProvider === "host" && executionEnvFactory))
29
+ ? createLeaderEndpoint(createLeaderRunner({
30
+ brain, models: config.models, roles: config.roles, pricing, logger,
31
+ fanoutEnabled: config.leaderFanoutEnabled,
32
+ // Route the single-vs-fanout classification on the cheap model (deepseek-v4-flash): the heavy
33
+ // reasoning worker model returns empty ~2/3 of the time on the route prompt → silent collapse to
34
+ // single (observed 2026-06-14). MODEL_ROUTER_ID overrides; else the cheap model; else default.
35
+ ...((process.env.MODEL_ROUTER_ID || process.env.MODEL_CHEAP_ID)
36
+ ? { routerModel: (process.env.MODEL_ROUTER_ID || process.env.MODEL_CHEAP_ID) }
37
+ : {}),
38
+ ...(leaderProvider === "e2b" ? { e2bApiKey: config.remoteExec.apiKey } : {}),
39
+ ...(executionEnvFactory ? { envFactory: executionEnvFactory } : {}),
40
+ ...leaderMinio,
41
+ // Durable offload store for sub-worker tool results — without it core REFUSES the durable
42
+ // suspend (InMemory offload would not survive a cross-replica resume) and the gated call is
43
+ // denied via the unwired onAsk fallback → the worker fails instead of suspending (drill c3).
44
+ ...(toolResultStore ? { toolResultStore } : {}),
45
+ // k8s lane: align the wire's workspace with the adapter's durable-snapshot root (/workspace) —
46
+ // the E2B default (/home/user) is OUTSIDE the k8s suspend tar, so a suspended sub-worker's repo
47
+ // would silently vanish on resume (drill c3: 105-byte empty snapshots).
48
+ ...(leaderProvider === "k8s" ? { workspace: "/workspace" } : {}),
49
+ // Same store as the main runner: a suspended sub-worker's session must be readable on /decide
50
+ // resume (sub-runner-local in-memory sessions die with "Entry <leafId> not found" on resume).
51
+ sessionStore,
52
+ // design/68 C4: sub-workers can durably suspend when the service runs durable approvals.
53
+ ...(checkpointStore && config.approvalRequire.length > 0
54
+ ? {
55
+ durable: {
56
+ checkpointStore,
57
+ requireApproval: config.approvalRequire,
58
+ deny: config.approvalDeny,
59
+ // design/80: the leader's orchestrated sub-agents adopt the SAME supervisor loop as the
60
+ // top-level agent — the D-E auto-budget (auto-approve NORMAL asks up to the budget, then
61
+ // escalate) + the never-auto safety set. Without these a sub-agent's gated ask always
62
+ // suspends-to-human, defeating the budget circuit-breaker for the supervised team.
63
+ autoBudget: config.approvalAutoBudget,
64
+ neverAuto: config.approvalNeverAuto,
65
+ ...(config.approvalTimeoutSec > 0 ? { ttlMs: config.approvalTimeoutSec * 1000 } : {}),
66
+ },
67
+ }
68
+ : {}),
69
+ }), {
70
+ logger,
71
+ // SDK 全量核查(2026-07-24,黑板 GD 组)撞获:wire.ts:368 的 runLeader 实际要求这五个字段全部
72
+ // 非空,此前同步门只查 objective,不完整请求会先拿到 202 再在后台必然失败——同步前移,让错误
73
+ // 尽早暴露而不是靠后续 GET 才发现。
74
+ requiredFields: ["objective", "durableRemote", "testCmd", "seedCmd", "baseSha"],
75
+ })
76
+ : undefined;
77
+ if (leaderEndpoint)
78
+ logger.info("leader_endpoint_enabled", { provider: leaderProvider, mode: "s3" in leaderMinio ? "factory" : "static" });
79
+ return leaderEndpoint;
80
+ }
81
+ //# sourceMappingURL=leader.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * design/158 A10:composition root 分段 —— 后台 reaper / 维护 tick。
3
+ *
4
+ * 纯搬运:函数体逐字来自 `main.ts`(原 2387-2599 行),缩进不变;新增的只有 import 与
5
+ * `startReapers(ctx)` 包壳。
6
+ *
7
+ * ⚠️ **位置即契约**:
8
+ * 1. 本段必须在 `createHttpServer` **之前**调用 —— D-D SLA-timer 的 deny-sweep 是 server 造出来的,
9
+ * 故经 `getRunDenySweep()` 晚绑(`let runDenySweep` 仍留在 main.ts,赋值点仍在 server 之后)。
10
+ * 这是本次搬运里**唯一**一行改写(`runDenySweep?.(…)` → `getRunDenySweep()?.(…)`)。
11
+ * 2. `reaper.unref?.()` 必须紧跟 `setInterval`(定时器绝不可持住进程),故一并搬进本函数、
12
+ * 返回值只交出 handle 给收尾段 `clearInterval`。
13
+ * 3. tick 内各腿的**相对次序**逐字保留:probeClockSkew(时钟指纹)先于任何按本地时钟分桶的清算。
14
+ */
15
+ import { Runner, type RunnerDeps, type WorkflowRunStore } from "@sema-agent/core";
16
+ import type { ServiceConfig } from "../config.js";
17
+ import { CostQuota } from "../observability/cost-quota.js";
18
+ import type { RateLimiter } from "../observability/rate-limit.js";
19
+ import type { Logger } from "../observability/logger.js";
20
+ import type { Metrics } from "../observability/metrics.js";
21
+ import type { WorkflowNotifyGate, WorkflowNotifyJournalStore } from "../orchestration/workflow-notify-journal.js";
22
+ import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
23
+ import type { ApprovalStore, CheckpointStoreFull, CostQuotaStore, ImageBake, RateLimiterStore, ServiceWorkflowJournalStore, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
24
+ export interface ReapersCtx {
25
+ config: ServiceConfig;
26
+ logger: Logger;
27
+ metrics: Metrics;
28
+ localRoot: string;
29
+ backend: StoreBackend | undefined;
30
+ subRunner: Runner;
31
+ runStore: ReturnType<StoreBackend["run"]> | undefined;
32
+ checkpointStore: CheckpointStoreFull | undefined;
33
+ approvalStore: ApprovalStore | undefined;
34
+ rateLimiter: RateLimiter | RateLimiterStore | undefined;
35
+ costQuota: CostQuota | CostQuotaStore | undefined;
36
+ toolResultStore: ToolResultStoreFull | undefined;
37
+ fileSnapshotStore: ReturnType<StoreBackend["fileSnapshot"]> | undefined;
38
+ taskAttachmentStore: TaskAttachmentStore | undefined;
39
+ imageBakes: ImageBake | undefined;
40
+ worktreeReap: (() => Promise<void>) | undefined;
41
+ workflowNotifyGate: WorkflowNotifyGate | undefined;
42
+ workflowJournalStore: ServiceWorkflowJournalStore | undefined;
43
+ sqlWorkflowRunStore: WorkflowRunStore | undefined;
44
+ workflowNotifyJournal: WorkflowNotifyJournalStore | undefined;
45
+ rosterStore: RunnerDeps["rosterStore"];
46
+ backgroundAgentStore: RunnerDeps["backgroundAgentStore"];
47
+ mailboxStore: RunnerDeps["mailboxStore"];
48
+ /** 晚绑(server 造出来才有)——见文件头「位置即契约」①。 */
49
+ getRunDenySweep: () => ((now: number) => Promise<void>) | undefined;
50
+ }
51
+ /** 起后台维护 tick,返回定时器 handle(收尾段 clearInterval 用)。 */
52
+ export declare function startReapers(ctx: ReapersCtx): NodeJS.Timeout;
53
+ //# sourceMappingURL=reapers.d.ts.map
@@ -0,0 +1,252 @@
1
+ /**
2
+ * design/158 A10:composition root 分段 —— 后台 reaper / 维护 tick。
3
+ *
4
+ * 纯搬运:函数体逐字来自 `main.ts`(原 2387-2599 行),缩进不变;新增的只有 import 与
5
+ * `startReapers(ctx)` 包壳。
6
+ *
7
+ * ⚠️ **位置即契约**:
8
+ * 1. 本段必须在 `createHttpServer` **之前**调用 —— D-D SLA-timer 的 deny-sweep 是 server 造出来的,
9
+ * 故经 `getRunDenySweep()` 晚绑(`let runDenySweep` 仍留在 main.ts,赋值点仍在 server 之后)。
10
+ * 这是本次搬运里**唯一**一行改写(`runDenySweep?.(…)` → `getRunDenySweep()?.(…)`)。
11
+ * 2. `reaper.unref?.()` 必须紧跟 `setInterval`(定时器绝不可持住进程),故一并搬进本函数、
12
+ * 返回值只交出 handle 给收尾段 `clearInterval`。
13
+ * 3. tick 内各腿的**相对次序**逐字保留:probeClockSkew(时钟指纹)先于任何按本地时钟分桶的清算。
14
+ */
15
+ import { Runner, defaultTaskRegistry } from "@sema-agent/core";
16
+ import { sweepStaleScratchpads } from "../env-facts.js";
17
+ import { CostQuota } from "../observability/cost-quota.js";
18
+ /** 起后台维护 tick,返回定时器 handle(收尾段 clearInterval 用)。 */
19
+ export function startReapers(ctx) {
20
+ const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, approvalStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, getRunDenySweep, } = ctx;
21
+ // S7 (SILENT-FALLBACK P0-d): the sweeps' return counts were discarded — an instance death that batch-fails
22
+ // N orphans was indistinguishable from organic failures. Count + log ONLY when a sweep flipped rows (>0),
23
+ // so healthy ticks stay silent. Tolerant of void-returning stores (typeof guard).
24
+ const reapCount = (metric, labels) => (n) => {
25
+ if (typeof n === "number" && n > 0) {
26
+ metrics.inc(metric, labels, n);
27
+ logger.info("reaper_swept", { metric, ...labels, count: n });
28
+ }
29
+ };
30
+ let wfRunReapInFlight = false; // 1.108 review (lens③): serialize the all-scopes retention sweep across ticks
31
+ let attachmentSweepInFlight = false; // 复审 F1:附件孤儿 sweep 的重入守卫(同上)
32
+ let bgAgentReapInFlight = false; // core 1.364: same serialization for the durable bg-agent joint reap (per-scope serial loop)
33
+ let worktreeReapInFlight = false; // A10 留档发现①(2026-07-29):worktreeReap 重入守卫(同上三个邻居——见下方调用点注释)
34
+ // core 1.364([1503] 提货单③):retention **只走** reapDurableAgents(联合 reap:条件删赢了才 release
35
+ // 转录 session;裸 store.reap 会 strand 转录)。staleRunning 翻转在 core 编排内先行(store.reap 一步)。
36
+ // scope 枚举:core 1.368 `BackgroundAgentStore.listScopes?()`([1516]② 交付,SQL twins/File 双实现
37
+ // 都带)——1.248 拍的「file 腿只扫 default」假设已撤;无该方法的第三方 store 仍回落 "default"(可选
38
+ // 接口成员,诚实回落)。
39
+ // sessions=subRunner.sessions —— [1522] 裁定件(③ 显式裁定形,双店真相考据后拍):bg 子代转录会话
40
+ // 是**双店**(ForkRoutingSessionStore):①非 fork 子代铸在 transient 店(TtlSessionStore,进程内
41
+ // 1h TTL)——release=内存删=**真正终结可寻址性**(转录本就不 durable;跨实例读面读的是行内
42
+ // finalOutput,非转录);②fork 子代铸在 host durable 店——release=lease 释放,durable 转录行**有意
43
+ // 保留**,留存归 session 留存策略/E21 purge 生命周期管(行先删可接受:行是执行记录,转录是会话资产,
44
+ // 两者生命周期本就不同)。core [1522] 复审只见 durable 腿判 HIGH——按其契约句「做不到终结可寻址性
45
+ // 宁可不传」,transient 类做得到、fork 类是显式裁定,维持传入。sessionsReleased 计数=「store 侧
46
+ // release 被调次数」(transient=真删,durable=lease 释放),非「durable 转录处置数」——如实注。
47
+ // 复审 F4 如实注:条件删的赢者若非写者副本,fork-routing 对未知 id 回落 transient=本地 no-op,写者
48
+ // 进程内 pinned 转录滞留到自身 TTL/进程退出——sessionsReleased 计数偏低是诚实读数,非缺陷。
49
+ const reapBgAgents = backgroundAgentStore
50
+ ? async () => {
51
+ const store = backgroundAgentStore;
52
+ const scopes = await (store.listScopes?.() ?? Promise.resolve(["default"]));
53
+ let rows = 0, sessions = 0, skipped = 0, failedScopes = 0;
54
+ for (const scope of scopes) {
55
+ // [1522] LOW3:per-scope 错误隔离——一个 scope 抛(坏 record_json/权限)不饿死后续 scope;
56
+ // 失败响亮(warn+计数),下 tick 重试。
57
+ try {
58
+ // core 1.382([1561] 提货单④,design/153 parked 状态机对账):先对账 parked 行(checkpoint
59
+ // expired/missing → 行诚实翻 failed;stale claim → 回滚 parked),再让下面既有的
60
+ // reapDurableAgents 按自然节奏处置(刚翻 failed 的行本 tick 不会立刻被 maxAge 删——它是
61
+ // "刚失败"的新行,不是"失败很久"的老行)。**走 registry 面**(裂脑 fence 内建于
62
+ // TaskRegistry.reconcileParkedAgents 内部,不裸调 store 面——那是无活实例场景专用,core
63
+ // [1561]④ 措辞)。仅在 checkpointStore 真在场时跑(同 [1561]①②「同车必接」判据——没有
64
+ // checkpointStore 就不可能有真正 parked 的行,调用本身没有意义)。
65
+ // 🔴 [1575] F1 修(cli 复查,红先行确认):`opts.staleClaimMaxAgeMs` 必须显式传——core 把
66
+ // 整个 stale-claim 清算块(claimer 崩死回滚 + resolved-未-finalize 诚实翻 failed)门在
67
+ // `staleMs !== undefined` 上,缺席不是"跳过 stale-claim 这一小步",是**整块永不运行**
68
+ // (此前漏传,`bg_agents_park_reconciled_rolledback_total` 恒 0,人已做出的审批决定会被
69
+ // 静默丢弃——见 config.ts backgroundAgentParkClaimStaleMs 顶注)。
70
+ if (checkpointStore) {
71
+ const pr = await defaultTaskRegistry.reconcileParkedAgents({ agentStore: store, checkpointStore }, scope, Date.now(), { staleClaimMaxAgeMs: config.backgroundAgentParkClaimStaleMs });
72
+ if (pr.failed > 0 || pr.rolledBack > 0) {
73
+ metrics.inc("bg_agents_park_reconciled_failed_total", {}, pr.failed);
74
+ metrics.inc("bg_agents_park_reconciled_rolledback_total", {}, pr.rolledBack);
75
+ logger.info("reaper_swept", { metric: "bg_agents_park_reconciled", scope, failed: pr.failed, rolledBack: pr.rolledBack });
76
+ }
77
+ }
78
+ // deps.mailbox(core 1.374,[1533]②):行删联动 F-13——agent 行 reap 时同 drop 其信箱
79
+ // (盒生命周期随行终结;不挂=信箱行无 retention 无界涨)。
80
+ const r = await defaultTaskRegistry.reapDurableAgents(scope, { store, sessions: subRunner.sessions, ...(mailboxStore ? { mailbox: mailboxStore } : {}) }, {
81
+ maxAgeMs: config.backgroundAgentRetentionMs,
82
+ staleRunningMaxAgeMs: config.backgroundAgentStaleRunningMs,
83
+ });
84
+ rows += r.rowsReaped;
85
+ sessions += r.sessionsReleased;
86
+ skipped += r.skippedNoSessions;
87
+ }
88
+ catch (err) {
89
+ failedScopes++;
90
+ logger.warn("bg_agent_reap_scope_failed", { scope, err: err instanceof Error ? err.message : String(err) });
91
+ }
92
+ }
93
+ if (failedScopes > 0)
94
+ metrics.inc("bg_agent_reap_scope_failures_total", {}, failedScopes);
95
+ if (rows > 0 || sessions > 0) {
96
+ metrics.inc("bg_agents_reaped_total", {}, rows);
97
+ logger.info("reaper_swept", { metric: "bg_agents_reaped_total", count: rows, sessionsReleased: sessions, skippedNoSessions: skipped });
98
+ }
99
+ }
100
+ : undefined;
101
+ // S10 (SILENT-FALLBACK P1): the write-behind counters bucket on LOCAL floor(now/windowMs) — replica clock
102
+ // skew vs the DB splits a fleet window into disjoint buckets (soft-limit leak) with zero visibility. Probe
103
+ // the DB clock each reaper tick; the gauge is the fleet-wide skew fingerprint (can be negative).
104
+ const probeClockSkew = async () => {
105
+ // Probe-ok gauge: a failing probe froze the skew gauge at its last value with zero signal —
106
+ // "probe ran, skew=N" vs "probe failing for 30 min" were indistinguishable.
107
+ try {
108
+ const dbMs = await backend?.dbNowMs?.();
109
+ if (typeof dbMs === "number" && Number.isFinite(dbMs)) {
110
+ metrics.setGauge("fleet_counter_bucket_skew_ms", dbMs - Date.now());
111
+ metrics.setGauge("fleet_clock_probe_ok", 1);
112
+ }
113
+ }
114
+ catch {
115
+ metrics.setGauge("fleet_clock_probe_ok", 0);
116
+ }
117
+ };
118
+ const reaper = setInterval(() => {
119
+ void probeClockSkew().catch(() => undefined); // S10
120
+ void runStore?.reapStale(config.runStaleSec * 1000).then(reapCount("runs_reaped_total", { kind: "stale" })).catch(() => undefined);
121
+ // Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
122
+ // deadline OR their absolute terminal_at backstop (≈ deny), then fail the suspended run rows whose
123
+ // checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
124
+ // REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at (stamped at put, never before an explicit deadline)
125
+ // bounds even a NULL-deadline pending checkpoint, so the backstop is the always-on safety net (it was
126
+ // inert when nested under the approvalTimeoutSec>0 guard — adversarial finding). The run-row half is
127
+ // checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at. Global +
128
+ // idempotent across replicas, no election.
129
+ if (checkpointStore) {
130
+ // 轴A #5 注释落档(1.254):core CheckpointStore.reap 契约把弃置臂的 unpin 义务派给部署 reaper——
131
+ // 真 pin 只存在于 in-memory TtlSessionStore;本部署 durable checkpoint 恒配 durable session 后端
132
+ // (pin=no-op),deny-sweep 走 resumeCheckpoint=core 内部 unpin ✓。SESSION_BACKEND=memory+durable
133
+ // checkpoint 的 niche dev 组合下 abort-expire 臂会把被钉会话泄到进程终——显式接受,不为 dev 形加腿。
134
+ void checkpointStore.reapExpired(Date.now()).then(reapCount("checkpoints_reaped_total", {})).catch(() => undefined);
135
+ void runStore?.failSuspendedWithExpiredCheckpoint().then(reapCount("runs_reaped_total", { kind: "expired_checkpoint" })).catch(() => undefined);
136
+ // D-D SLA-timer: resolve-DENY human/irreversible_ask gates past their deadline (graceful — the model
137
+ // continues with the denial), vs the abort reapExpired gives the other kinds. Bounded per tick.
138
+ void getRunDenySweep()?.(Date.now()).catch(() => undefined); // A10 搬运改写:晚绑取值(原 `runDenySweep?.`)
139
+ }
140
+ // The finer, OPT-IN per-approval TTL sweeps (APPROVAL_TIMEOUT_SEC): the F4 poll-gate store + the
141
+ // time-based suspended-run reaper. Stay gated — reapSuspended with a 0 TTL would nuke ALL suspended rows
142
+ // (cutoff = now). The absolute backstop above is the safety floor; this is the operator-chosen deadline.
143
+ if (config.approvalTimeoutSec > 0) {
144
+ void approvalStore?.expireStale(config.approvalTimeoutSec * 1000).catch(() => undefined);
145
+ void runStore?.reapSuspended(config.approvalTimeoutSec * 1000).then(reapCount("runs_reaped_total", { kind: "suspended" })).catch(() => undefined);
146
+ }
147
+ // GC checkpoint_ctx rows whose checkpoint is gone (bound to the checkpoint lifecycle).
148
+ void checkpointStore?.reapCtx(Date.now() - config.runStaleSec * 1000).catch(() => undefined);
149
+ rateLimiter?.sweep();
150
+ if (costQuota instanceof CostQuota)
151
+ costQuota.reap(); // TiDB variant self-reaps in its flush loop
152
+ void toolResultStore?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)?.catch(() => undefined); // optional extra: SQL twins only (the local FileToolResultStore persists like transcripts)
153
+ // 2c session-sync: GLOBALLY GC grace-window orphan blobs (standalone /sync/blobs PUTs that were never
154
+ // imported are otherwise collected only on reap()/deleteBySession(), which never fire for a never-imported scope).
155
+ // Bounds the standalone-PUT orphan-blob exhaustion (review finding) to the grace window. Durable twins only (local
156
+ // omits the seam → optional-chained no-op).
157
+ void fileSnapshotStore?.sweepOrphanBlobs?.().catch(() => undefined);
158
+ // D-1 附件 TTL:上传后从未被任何 task 引用(session_id NULL)且超过 attachmentUnboundTtlMs 的行收割
159
+ // (绑定行不在此收——随会话 E21 级联删)。best-effort,与其余 reaper 腿同姿。
160
+ void taskAttachmentStore?.reapUnbound(Date.now() - config.attachmentUnboundTtlMs).then(reapCount("attachments_reaped_total", {})).catch(() => undefined);
161
+ // D-1 孤儿**对象**彻底 GC(clay 拍 2026-07-28):对象存储 × meta 行对账,grace 默认 1h(上传先行窗
162
+ // 保护;`ATTACHMENT_ORPHAN_GRACE_MS=0` 关掉本腿——同 tick 邻居都有旋钮,复审 F10)。
163
+ // 列举失败=本轮 warn 跳过(「列不出来」绝不当「没有孤儿」),下轮再试。
164
+ // 🔴 复审 F1:必须 in-flight 守卫——本腿时长随对象总数增长(20 万对象实测秒级、百万级可达 30s),
165
+ // 无守卫时超过 tick 间隔即逐 tick 叠加(实测叠 6 层),同时压 LIST/SQL/DELETE 三面。邻居
166
+ // wfRunReap/bgAgentReap 都有同款守卫。
167
+ if (config.attachmentOrphanGraceMs > 0 && !attachmentSweepInFlight && taskAttachmentStore?.sweepOrphanObjects) {
168
+ attachmentSweepInFlight = true;
169
+ void taskAttachmentStore
170
+ .sweepOrphanObjects(config.attachmentOrphanGraceMs)
171
+ .then((n) => { if (n > 0) {
172
+ metrics.inc("attachment_orphan_objects_swept_total", {}, n);
173
+ logger.info("attachment_orphan_objects_swept", { removed: n });
174
+ } })
175
+ .catch((err) => logger.warn("attachment_orphan_sweep_failed", { err: String(err) }))
176
+ .finally(() => { attachmentSweepInFlight = false; });
177
+ }
178
+ // P1d-β 2c session-sync: GC ABANDONED staged imports — staging-id session_event rows (`%#stg-%`) with NO
179
+ // session_meta whose oldest row is older than the grace window (an in-flight stream stays fresh → never reaped).
180
+ // Bounds the orphan-staging-row growth (a Phase B that opened a staging then never committed). Durable session
181
+ // stores only (the local backend stages in memory → no durable rows; the seam is absent → optional-chained no-op).
182
+ void backend?.session()?.sweepStagingSessions?.().catch(() => undefined);
183
+ // Bake-runner backstop (§P2.7): fail any `running` bake whose lease went stale (the runner crashed mid-build),
184
+ // append a synthetic terminal `done{failed}` so SSE readers settle, and force-release the single-flight lease.
185
+ // staleMs (≈3× the 30s heartbeat) bounds a healthy slow build so it is never wrongly reaped.
186
+ void imageBakes?.reapStaleBakes(config.imageBakes.staleMs).then(reapCount("bakes_reaped_total", {})).catch(() => undefined);
187
+ // SVC-3 worktree isolation: deregister worktrees orphaned by a process crash (the Runner never reached
188
+ // destroy → `git worktree remove` never ran). `git worktree prune` cleans registrations whose dirs are
189
+ // already gone. Best-effort, userland (core ships no post-kill Runner hook); never throws. Unset = no-op.
190
+ // 🔴 A10 留档发现①(review 2026-07-29):必须 in-flight 守卫——`git worktree prune` 随仓内 worktree
191
+ // 总数/子代并发度增长可变慢,无守卫时超过 tick 间隔即在下一 tick 被重入叠跑(邻居
192
+ // attachmentSweep/wfRunReap/bgAgentReap 三个 sweep 都已有同款守卫,此腿此前是唯一漏配的)。
193
+ // 照抄 bgAgentReap 的 in-flight + finally 释放形。
194
+ if (worktreeReap && !worktreeReapInFlight) {
195
+ worktreeReapInFlight = true;
196
+ void worktreeReap().catch(() => undefined).finally(() => (worktreeReapInFlight = false));
197
+ }
198
+ // SVC-1 (adversarial-review HIGH): PERIODIC notify-recovery sweep (not just at boot) — re-delivers a terminal
199
+ // run whose in-process notify was lost, AND finalizes-as-abandoned a `running` run orphaned past the grace
200
+ // window (core never resumes/reaps a prior `running` row, so nothing else would). Idempotent; best-effort.
201
+ void workflowNotifyGate?.recover({ orphanGraceMs: config.workflowOrphanGraceMs }).catch(() => undefined);
202
+ // SVC-2 (adversarial-review HIGH): time-based GC of the workflow_journal table (the heaviest, TaskResult-bearing
203
+ // one) — the per-run deleteByRun has no run-store reap hook, so this bounded sweep is what stops unbounded
204
+ // growth. A resume of a journal older than the retention window re-runs live (resume is an optimization).
205
+ void workflowJournalStore?.reapExpired?.(Date.now(), config.workflowJournalRetentionMs).catch(() => undefined);
206
+ // Retention (WorkflowRunStore never auto-purges, reap is explicit):
207
+ // age out TERMINAL workflow_run rows across ALL scopes. SQL twins only (reapAllScopes is their DISTINCT-scope
208
+ // extension; the contract itself has no cross-scope enumeration, and the File store keeps the transcripts-like
209
+ // keep-everything posture). Running rows are untouched — the orphan-grace sweep above owns those.
210
+ // In-flight guard (1.108 review, lens③): the sweep enumerates EVERY scope serially — under a short
211
+ // REAP_INTERVAL_SEC + many scopes, overlapping sweeps would pile up on the pool. One at a time.
212
+ if (!wfRunReapInFlight) {
213
+ const sweep = sqlWorkflowRunStore?.reapAllScopes?.(Date.now(), { maxAgeMs: config.workflowRunRetentionMs });
214
+ if (sweep) {
215
+ wfRunReapInFlight = true;
216
+ void sweep.catch(() => undefined).finally(() => (wfRunReapInFlight = false));
217
+ }
218
+ // 1.108: SQL notify-journal retention rides the same knob — ACKED rows are pure history (pending rows are
219
+ // the recovery backlog and are NEVER reaped; the orphan-grace sweep retires a stuck pending run).
220
+ void workflowNotifyJournal
221
+ ?.reapAcked?.(Date.now() - config.workflowRunRetentionMs)
222
+ .catch(() => undefined);
223
+ }
224
+ // core 1.364 durable bg-agent joint reap(定义在 interval 上方,契约注释在彼)。in-flight 守卫同
225
+ // wfRun sweep(per-scope 串行循环,短 tick + 多 scope 下不叠罗汉)。
226
+ if (reapBgAgents && !bgAgentReapInFlight) {
227
+ bgAgentReapInFlight = true;
228
+ void reapBgAgents().catch(() => undefined).finally(() => (bgAgentReapInFlight = false));
229
+ }
230
+ // [1522] MED2:agent_roster TTL 清理(core RB-23②③ 派给部署的半场)——SQL twins 扩展面
231
+ // (reapOlderThan,duck probe;File/Memory 店 core 自带 maxAgeMs,无此面=no-op)。
232
+ void rosterStore
233
+ ?.reapOlderThan?.(Date.now() - config.rosterRetentionMs)
234
+ .then(reapCount("roster_rows_reaped_total", {}))
235
+ .catch(() => undefined);
236
+ // 修8(三路复审 absorb-2,接线 (b)):periodic scratchpad sweep — 无 E21 purge 兜到的孤儿目录(session 从未
237
+ // DELETE、purge 当次失败、local 后端无 purge coordinator)按 mtime 过期回收。SCRATCHPAD_SWEEP_TTL_MS
238
+ // (default 7d,0=禁用)。activeSessionIds 不传:活跃判据的诚实边界在 env-facts.ts 的 sweep 文档——TTL 7d
239
+ // 远大于任何在飞任务;一个 7 天零写入的 scratchpad 被回收是可接受的(它本就是临时区,fact 文案即如此宣示)。
240
+ if (config.scratchpadSweepTtlMs > 0) {
241
+ void sweepStaleScratchpads(config.localDataRoot ?? localRoot, { olderThanMs: config.scratchpadSweepTtlMs })
242
+ .then((removed) => {
243
+ if (removed > 0)
244
+ logger.info("scratchpads_swept", { removed });
245
+ })
246
+ .catch(() => undefined);
247
+ }
248
+ }, config.reapIntervalSec * 1000);
249
+ reaper.unref?.();
250
+ return reaper;
251
+ }
252
+ //# sourceMappingURL=reapers.js.map
@@ -0,0 +1,70 @@
1
+ import { type MemoryBackend, type RunnerDeps } from "@sema-agent/core";
2
+ import { type PromptsDomainFaces } from "../capabilities/center-prompts.js";
3
+ import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilities/select-environment-tool.js";
4
+ import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
5
+ import { selectScenario } from "../capabilities/scenarios.js";
6
+ import type { ServiceConfig } from "../config.js";
7
+ import { FleetEventBus } from "../fleet/fleet-bus.js";
8
+ import { type HookLlmCall } from "../hooks/hook-runner.js";
9
+ import type { createHookLlm } from "../hooks/hook-llm.js";
10
+ import { type ServiceDeps } from "../http/server.js";
11
+ import type { createKeyResolver } from "../key-resolver.js";
12
+ import type { Logger } from "../observability/logger.js";
13
+ import type { Metrics } from "../observability/metrics.js";
14
+ import { PerTaskImageRegistry } from "../per-task-image.js";
15
+ import type { StoreBackend } from "../plugins/store-backend.js";
16
+ import { type TaskAttachmentStore } from "../plugins/task-attachment-store.js";
17
+ import { type createPrincipalEntitlementsClient } from "../runtime-caps-resolver.js";
18
+ import { type OwnerAwareSessionStore } from "../security.js";
19
+ type PrincipalCaps = ReturnType<typeof createPrincipalEntitlementsClient>;
20
+ /** `resolveSpec` 原先从 `main()` 闭包里拿到的全部 boot 局部量。 */
21
+ export interface ResolveSpecCtx {
22
+ config: ServiceConfig;
23
+ logger: Logger;
24
+ metrics: Metrics;
25
+ localRoot: string;
26
+ scenarios: Record<string, ReturnType<typeof selectScenario>>;
27
+ principalCaps: PrincipalCaps | undefined;
28
+ centerRuntimeCapsResolver: PrincipalCaps["resolveRuntimeCaps"] | undefined;
29
+ /** 活引用(adoptCenterPrompts 每次采用换引用)—— 见文件头「位置即契约」。 */
30
+ getCenterPrompts: () => PromptsDomainFaces | undefined;
31
+ /** 活引用(registry 热应用换引用)—— 见文件头「位置即契约」。 */
32
+ getKeyResolver: () => ReturnType<typeof createKeyResolver>;
33
+ taskAttachmentStore: TaskAttachmentStore | undefined;
34
+ perSessionCwd: Map<string, string>;
35
+ setSessionCwd: (sid: string, cwd: string) => void;
36
+ setSessionShellEnv: (sid: string, env: Record<string, string>) => void;
37
+ hookLlm: ReturnType<typeof createHookLlm>["hookLlm"];
38
+ hookAgent: HookLlmCall;
39
+ fleetBus: FleetEventBus;
40
+ hookWakeBus: {
41
+ deliver?: (sessionId: string, text: string) => Promise<boolean>;
42
+ };
43
+ resumeAnchorStore: ReturnType<StoreBackend["resumeAnchor"]> | undefined;
44
+ ownerAware: OwnerAwareSessionStore;
45
+ taskLimitCaps: {
46
+ timeoutSec: number | undefined;
47
+ maxOutputTokens: number | undefined;
48
+ maxTurns: number | undefined;
49
+ };
50
+ taskTimeoutSec: number;
51
+ selectEnvTool: ReturnType<typeof selectEnvironmentTool> | undefined;
52
+ sendUserFileToolSpec: ReturnType<typeof sendUserFileTool> | undefined;
53
+ memoryEngine: {
54
+ backend: MemoryBackend;
55
+ root: string;
56
+ } | undefined;
57
+ durableEnabled: boolean;
58
+ approvalExemptionStore: ReturnType<StoreBackend["approvalExemption"]> | undefined;
59
+ approvalEnabled: boolean | undefined;
60
+ approvalStore: ReturnType<NonNullable<StoreBackend["approval"]>> | undefined;
61
+ singleUserAutoAcceptBaseline: boolean;
62
+ checkpointStore: ReturnType<NonNullable<StoreBackend["checkpoint"]>> | undefined;
63
+ deploymentHooks: NonNullable<RunnerDeps["hooks"]>;
64
+ imageIndex: ReturnType<NonNullable<StoreBackend["imageIndex"]>> | undefined;
65
+ perTaskImage: PerTaskImageRegistry;
66
+ sessionEnvSelection: SessionEnvironmentSelection;
67
+ }
68
+ export declare function createResolveSpec(ctx: ResolveSpecCtx): ServiceDeps["resolveSpec"];
69
+ export {};
70
+ //# sourceMappingURL=resolve-spec.d.ts.map