@sema-agent/server 7.9.0 → 7.10.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/USAGE.md +6 -0
- package/dist/approval-reconciler.d.ts +1 -1
- package/dist/approval-reconciler.js +1 -1
- package/dist/boot/reapers.js +3 -3
- package/dist/capabilities/repo-tools.d.ts +36 -2
- package/dist/capabilities/repo-tools.js +125 -11
- package/dist/capabilities/scenarios.d.ts +2 -2
- package/dist/capabilities/scenarios.js +1 -1
- package/dist/config-types.d.ts +6 -1
- package/dist/config.js +39 -1
- package/dist/fleet/fleet-terminal-window.d.ts +1 -1
- package/dist/fleet/fleet-terminal-window.js +1 -1
- package/dist/git-api-kind.d.ts +7 -0
- package/dist/git-api-kind.js +8 -0
- package/dist/http/routes/approvals-assistant.js +2 -2
- package/dist/http/server.js +5 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/main.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +9 -9
- package/dist/plugins/checkpoint-store-sql.js +29 -29
- package/dist/plugins/local-checkpoint-store.d.ts +1 -1
- package/dist/plugins/local-checkpoint-store.js +2 -2
- package/dist/plugins/memory-engine-pg.js +3 -3
- package/dist/plugins/pg-pool.js +7 -7
- package/dist/plugins/run-store-sql.d.ts +3 -3
- package/dist/plugins/run-store-sql.js +3 -3
- package/dist/plugins/tidb-pool.js +8 -8
- package/dist/plugins/workflow-journal-store-sql.d.ts +3 -3
- package/dist/plugins/workflow-journal-store-sql.js +6 -6
- package/dist/plugins/workflow-run-store-sql.d.ts +1 -1
- package/dist/plugins/workflow-run-store-sql.js +12 -12
- package/dist/run-local.js +2 -2
- package/package.json +1 -1
package/USAGE.md
CHANGED
|
@@ -413,6 +413,12 @@ curl -s http://<host>:8090/v1/runs/<taskId> -H 'x-agent-principal: user:42' #
|
|
|
413
413
|
> 不是裸主机名——服务端直接拼 `${GIT_API_BASEURL}/api/v1/...` 发请求,裸 `git.example.com` 会拼出非法 URL 而 fetch 失败。
|
|
414
414
|
> 末尾不要带 `/`,也不要把 `/api/v1` 写进来。
|
|
415
415
|
|
|
416
|
+
> **`GIT_API_KIND`(7.10.0 起)**:Git host 方言,闭集 `gitea`(缺省)/ `github`。GitHub 托管仓
|
|
417
|
+
> (github.com 或 GHE)设 `GIT_API_KIND=github`,此时 `GIT_API_BASEURL` 写 **API 根**(github.com 用
|
|
418
|
+
> `https://api.github.com`;GHE 用 `https://ghe.example.com/api/v3`),token 用 fine-grained PAT
|
|
419
|
+
> (仅 contents/pull-requests 只读)。未知词拒启;`GIT_API_BASEURL` 指向 github.com 族而 kind 仍是
|
|
420
|
+
> gitea 也拒启并指路(Gitea 形状对 GitHub 恒 404,启动即报优于运行时逐调用 404)。
|
|
421
|
+
|
|
416
422
|
> **升级注记(7.7.0)**:本版起 `code-review` / `scan` / `team` / `toolset: repo-readonly|none` 的场景改跑
|
|
417
423
|
> 无执行环境的引擎。**升级前**若还有这些场景的 durable 挂起任务(需同时满足:配了 `REMOTE_EXEC`、开了
|
|
418
424
|
> `DURABLE_APPROVAL`、任务已 park),它们在新版上续跑会以 409 `checkpoint.unsupported_version` 响亮失败
|
|
@@ -167,7 +167,7 @@ export declare function isAncestorFoldMint(ask: AskRow): boolean;
|
|
|
167
167
|
* 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。
|
|
168
168
|
*
|
|
169
169
|
* 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
|
|
170
|
-
* `
|
|
170
|
+
* `created_at_ms ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
|
|
171
171
|
* 一个还能被 resume 的坐标上,对壳更有用。
|
|
172
172
|
*/
|
|
173
173
|
export declare function classifyGateMatch(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): GateMatchOutcome;
|
|
@@ -84,7 +84,7 @@ export function isAncestorFoldMint(ask) {
|
|
|
84
84
|
* 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。
|
|
85
85
|
*
|
|
86
86
|
* 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
|
|
87
|
-
* `
|
|
87
|
+
* `created_at_ms ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
|
|
88
88
|
* 一个还能被 resume 的坐标上,对壳更有用。
|
|
89
89
|
*/
|
|
90
90
|
export function classifyGateMatch(ask, candidates) {
|
package/dist/boot/reapers.js
CHANGED
|
@@ -244,12 +244,12 @@ export function startReapers(ctx) {
|
|
|
244
244
|
.then(reapCount("runs_reaped_total", { kind: "stale" }))
|
|
245
245
|
.then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError);
|
|
246
246
|
// Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
|
|
247
|
-
// deadline OR their absolute
|
|
247
|
+
// deadline OR their absolute terminal_at_ms backstop (≈ deny), then fail the suspended run rows whose
|
|
248
248
|
// checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
|
|
249
|
-
// REGARDLESS of APPROVAL_TIMEOUT_SEC —
|
|
249
|
+
// REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at_ms (stamped at put, never before an explicit deadline)
|
|
250
250
|
// bounds even a NULL-deadline pending checkpoint, so the backstop is the always-on safety net (it was
|
|
251
251
|
// inert when nested under the approvalTimeoutSec>0 guard — adversarial finding). The run-row half is
|
|
252
|
-
// checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row
|
|
252
|
+
// checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at_ms. Global +
|
|
253
253
|
// idempotent across replicas, no election.
|
|
254
254
|
if (checkpointStore) {
|
|
255
255
|
// 轴A #5 注释落档(1.254):core CheckpointStore.reap 契约把弃置臂的 unpin 义务派给部署 reaper——
|
|
@@ -13,8 +13,42 @@ export interface RepoCoords {
|
|
|
13
13
|
}
|
|
14
14
|
/** Parse `owner/name` or a repo URL (e.g. https://git.example.com/some-org/some-repo.git). */
|
|
15
15
|
export declare function parseRepo(input: string): RepoCoords;
|
|
16
|
+
import type { GitApiKind } from "../git-api-kind.js";
|
|
17
|
+
export { GIT_API_KINDS, isGitApiKind, type GitApiKind } from "../git-api-kind.js";
|
|
18
|
+
/** The read-only surface the repo tools are built over — one implementation per Git-host dialect. */
|
|
19
|
+
export interface RepoReadClient {
|
|
20
|
+
defaultBranch(coords: RepoCoords): Promise<string>;
|
|
21
|
+
tree(coords: RepoCoords, ref: string): Promise<string[]>;
|
|
22
|
+
readFile(coords: RepoCoords, path: string, ref: string): Promise<string>;
|
|
23
|
+
pullDiff(coords: RepoCoords, index: number): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
export declare function createRepoClient(kind: GitApiKind, baseUrl: string, token?: string, fetchImpl?: typeof fetch): RepoReadClient;
|
|
16
26
|
/** Thin read-only Gitea API client. Built once at startup; bound to a repo per task. */
|
|
17
|
-
export declare class GiteaClient {
|
|
27
|
+
export declare class GiteaClient implements RepoReadClient {
|
|
28
|
+
private readonly baseUrl;
|
|
29
|
+
private readonly token?;
|
|
30
|
+
private readonly fetchImpl;
|
|
31
|
+
constructor(baseUrl: string, token?: string | undefined, fetchImpl?: typeof fetch);
|
|
32
|
+
private api;
|
|
33
|
+
defaultBranch({ owner, repo }: RepoCoords): Promise<string>;
|
|
34
|
+
tree(coords: RepoCoords, ref: string): Promise<string[]>;
|
|
35
|
+
readFile(coords: RepoCoords, path: string, ref: string): Promise<string>;
|
|
36
|
+
pullDiff(coords: RepoCoords, index: number): Promise<string>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Thin read-only GitHub (github.com / GHE) API client — same four-call surface as `GiteaClient`.
|
|
40
|
+
*
|
|
41
|
+
* Dialect differences (each one is why `GiteaClient` alone 404s against GitHub):
|
|
42
|
+
* - base URL is the API root itself (`https://api.github.com`, GHE `https://ghe.example/api/v3`) —
|
|
43
|
+
* no `/api/v1` prefix is appended;
|
|
44
|
+
* - auth is `Authorization: Bearer <token>` (Gitea uses `token <token>`);
|
|
45
|
+
* - a PR's unified diff comes from `/pulls/{n}` via `Accept: application/vnd.github.v3.diff`
|
|
46
|
+
* (Gitea uses a `.diff` URL suffix);
|
|
47
|
+
* - the tree API has no `per_page` cap — GitHub returns up to its own limit and reports overflow via
|
|
48
|
+
* `truncated`; we additionally cap client-side at MAX_TREE_ENTRIES so the model-facing bound is the
|
|
49
|
+
* same for both dialects.
|
|
50
|
+
*/
|
|
51
|
+
export declare class GitHubClient implements RepoReadClient {
|
|
18
52
|
private readonly baseUrl;
|
|
19
53
|
private readonly token?;
|
|
20
54
|
private readonly fetchImpl;
|
|
@@ -26,5 +60,5 @@ export declare class GiteaClient {
|
|
|
26
60
|
pullDiff(coords: RepoCoords, index: number): Promise<string>;
|
|
27
61
|
}
|
|
28
62
|
/** Build the read-only repo tool set bound to one repo (cheap per-task closure; the client is shared). */
|
|
29
|
-
export declare function repoToolsFor(client:
|
|
63
|
+
export declare function repoToolsFor(client: RepoReadClient, coords: RepoCoords): ToolSpec[];
|
|
30
64
|
//# sourceMappingURL=repo-tools.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
/** Parse `owner/name` or a repo URL (e.g. https://git.example.com/some-org/some-repo.git). */
|
|
3
4
|
export function parseRepo(input) {
|
|
4
5
|
const s = input.trim().replace(/\.git$/, "");
|
|
@@ -13,6 +14,43 @@ const MAX_FILE_BYTES = 96 * 1024;
|
|
|
13
14
|
const MAX_TREE_ENTRIES = 2000;
|
|
14
15
|
/** Bound each Git-host call so a slow/hung host can't pin a review lens to its whole deadline. */
|
|
15
16
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
17
|
+
export { GIT_API_KINDS, isGitApiKind } from "../git-api-kind.js";
|
|
18
|
+
/** Wire schemas for the Git-host responses we consume (both dialects share these shapes — Gitea copied
|
|
19
|
+
* GitHub's). Boundary discipline(宪法 [2704] §2):no bare `as` on `.json()`. */
|
|
20
|
+
const RepoInfoWire = z.looseObject({ default_branch: z.string().optional() });
|
|
21
|
+
const TreeWire = z.looseObject({
|
|
22
|
+
tree: z.array(z.looseObject({ path: z.string(), type: z.string() })).optional(),
|
|
23
|
+
truncated: z.boolean().optional(),
|
|
24
|
+
});
|
|
25
|
+
const ContentsWire = z.looseObject({
|
|
26
|
+
content: z.string().optional().nullable(),
|
|
27
|
+
encoding: z.string().optional(),
|
|
28
|
+
type: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
const PullMetaWire = z.looseObject({ changed_files: z.number().optional() });
|
|
31
|
+
/** Shared guard: `encodeURIComponent` does NOT escape "." — a `..` segment survives the per-segment
|
|
32
|
+
* encode and the WHATWG URL parser collapses it when `fetch` builds the request, letting a crafted
|
|
33
|
+
* `path` climb out of `/repos/<owner>/<repo>/contents/` to any endpoint the read token can reach.
|
|
34
|
+
* The tools are contractually bound to exactly one repo, so reject traversal before it hits fetch. */
|
|
35
|
+
function pathSegmentsRejectingTraversal(path) {
|
|
36
|
+
const segments = path.split("/").filter(Boolean);
|
|
37
|
+
if (segments.some((s) => s === "..")) {
|
|
38
|
+
throw new Error(`invalid path "${path}" (".." segments are not allowed)`);
|
|
39
|
+
}
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
42
|
+
export function createRepoClient(kind, baseUrl, token, fetchImpl = fetch) {
|
|
43
|
+
switch (kind) {
|
|
44
|
+
case "gitea":
|
|
45
|
+
return new GiteaClient(baseUrl, token, fetchImpl);
|
|
46
|
+
case "github":
|
|
47
|
+
return new GitHubClient(baseUrl, token, fetchImpl);
|
|
48
|
+
default: {
|
|
49
|
+
const never = kind;
|
|
50
|
+
throw new Error(`unknown gitApiKind ${String(never)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
16
54
|
/** Thin read-only Gitea API client. Built once at startup; bound to a repo per task. */
|
|
17
55
|
export class GiteaClient {
|
|
18
56
|
baseUrl;
|
|
@@ -36,12 +74,12 @@ export class GiteaClient {
|
|
|
36
74
|
return res;
|
|
37
75
|
}
|
|
38
76
|
async defaultBranch({ owner, repo }) {
|
|
39
|
-
const info = (await (await this.api(`/repos/${owner}/${repo}`)).json());
|
|
77
|
+
const info = RepoInfoWire.parse(await (await this.api(`/repos/${owner}/${repo}`)).json());
|
|
40
78
|
return info.default_branch || "main";
|
|
41
79
|
}
|
|
42
80
|
async tree(coords, ref) {
|
|
43
81
|
const { owner, repo } = coords;
|
|
44
|
-
const body = (await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=${MAX_TREE_ENTRIES}`)).json());
|
|
82
|
+
const body = TreeWire.parse(await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=${MAX_TREE_ENTRIES}`)).json());
|
|
45
83
|
const paths = (body.tree ?? []).filter((e) => e.type === "blob").map((e) => e.path);
|
|
46
84
|
// The upstream API caps the listing at `per_page` and reports the overflow via `truncated` — it does
|
|
47
85
|
// not report how many entries were left out. Surface that (same disclosure-marker family as
|
|
@@ -52,15 +90,8 @@ export class GiteaClient {
|
|
|
52
90
|
}
|
|
53
91
|
async readFile(coords, path, ref) {
|
|
54
92
|
const { owner, repo } = coords;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// `/repos/<owner>/<repo>/contents/` to any endpoint the read token can reach (cross-repo / admin). The
|
|
58
|
-
// tools are contractually bound to exactly one repo (see header), so reject traversal before it hits fetch.
|
|
59
|
-
const segments = path.split("/").filter(Boolean);
|
|
60
|
-
if (segments.some((s) => s === "..")) {
|
|
61
|
-
throw new Error(`invalid path "${path}" (".." segments are not allowed)`);
|
|
62
|
-
}
|
|
63
|
-
const body = (await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
|
|
93
|
+
const segments = pathSegmentsRejectingTraversal(path);
|
|
94
|
+
const body = ContentsWire.parse(await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
|
|
64
95
|
if (body.type !== "file" || body.content == null)
|
|
65
96
|
throw new Error(`not a file: ${path}`);
|
|
66
97
|
const text = body.encoding === "base64" ? Buffer.from(body.content, "base64").toString("utf8") : body.content;
|
|
@@ -75,6 +106,89 @@ export class GiteaClient {
|
|
|
75
106
|
return text.length > MAX_FILE_BYTES * 4 ? text.slice(0, MAX_FILE_BYTES * 4) + "\n…[diff truncated]" : text;
|
|
76
107
|
}
|
|
77
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Thin read-only GitHub (github.com / GHE) API client — same four-call surface as `GiteaClient`.
|
|
111
|
+
*
|
|
112
|
+
* Dialect differences (each one is why `GiteaClient` alone 404s against GitHub):
|
|
113
|
+
* - base URL is the API root itself (`https://api.github.com`, GHE `https://ghe.example/api/v3`) —
|
|
114
|
+
* no `/api/v1` prefix is appended;
|
|
115
|
+
* - auth is `Authorization: Bearer <token>` (Gitea uses `token <token>`);
|
|
116
|
+
* - a PR's unified diff comes from `/pulls/{n}` via `Accept: application/vnd.github.v3.diff`
|
|
117
|
+
* (Gitea uses a `.diff` URL suffix);
|
|
118
|
+
* - the tree API has no `per_page` cap — GitHub returns up to its own limit and reports overflow via
|
|
119
|
+
* `truncated`; we additionally cap client-side at MAX_TREE_ENTRIES so the model-facing bound is the
|
|
120
|
+
* same for both dialects.
|
|
121
|
+
*/
|
|
122
|
+
export class GitHubClient {
|
|
123
|
+
baseUrl;
|
|
124
|
+
token;
|
|
125
|
+
fetchImpl;
|
|
126
|
+
constructor(baseUrl, token, fetchImpl = fetch) {
|
|
127
|
+
this.baseUrl = baseUrl;
|
|
128
|
+
this.token = token;
|
|
129
|
+
this.fetchImpl = fetchImpl;
|
|
130
|
+
}
|
|
131
|
+
async api(path, accept = "application/vnd.github+json") {
|
|
132
|
+
const headers = { accept, "x-github-api-version": "2022-11-28" };
|
|
133
|
+
if (this.token)
|
|
134
|
+
headers.authorization = `Bearer ${this.token}`;
|
|
135
|
+
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
136
|
+
headers,
|
|
137
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok)
|
|
140
|
+
throw new Error(`git api ${path} → HTTP ${res.status}`);
|
|
141
|
+
return res;
|
|
142
|
+
}
|
|
143
|
+
async defaultBranch({ owner, repo }) {
|
|
144
|
+
const info = RepoInfoWire.parse(await (await this.api(`/repos/${owner}/${repo}`)).json());
|
|
145
|
+
return info.default_branch || "main";
|
|
146
|
+
}
|
|
147
|
+
async tree(coords, ref) {
|
|
148
|
+
const { owner, repo } = coords;
|
|
149
|
+
const body = TreeWire.parse(await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`)).json());
|
|
150
|
+
const paths = (body.tree ?? []).filter((e) => e.type === "blob").map((e) => e.path);
|
|
151
|
+
// Same disclosure-marker family as GiteaClient.tree: a capped listing must never read as the whole
|
|
152
|
+
// repo. Two cap sources here — upstream `truncated`, and our own client-side MAX_TREE_ENTRIES bound.
|
|
153
|
+
if (paths.length > MAX_TREE_ENTRIES) {
|
|
154
|
+
return [...paths.slice(0, MAX_TREE_ENTRIES), `…[truncated, tree has more than ${MAX_TREE_ENTRIES} entries]`];
|
|
155
|
+
}
|
|
156
|
+
return body.truncated
|
|
157
|
+
? [...paths, `…[truncated, tree has more than ${MAX_TREE_ENTRIES} entries]`]
|
|
158
|
+
: paths;
|
|
159
|
+
}
|
|
160
|
+
async readFile(coords, path, ref) {
|
|
161
|
+
const { owner, repo } = coords;
|
|
162
|
+
const segments = pathSegmentsRejectingTraversal(path);
|
|
163
|
+
const body = ContentsWire.parse(await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
|
|
164
|
+
if (body.type !== "file" || body.content == null)
|
|
165
|
+
throw new Error(`not a file: ${path}`);
|
|
166
|
+
// GitHub 文档行为(codex F1 定谳):1–100 MB 文件走对象形返回 `content:"", encoding:"none"` ——
|
|
167
|
+
// content 非 null 过上面的门,非 base64 臂又会把空串当正文交出 = 「大文件被当空文件评审」的静默
|
|
168
|
+
// 完整性失败。显式臂响亮拒(本工具本地帽 96 KiB,>1 MB 内容也装不下,拒绝即诚实)。
|
|
169
|
+
if (body.encoding === "none") {
|
|
170
|
+
throw new Error(`file too large for the contents API: ${path} (GitHub returns encoding:"none" for files over 1 MB — this read tool cannot fetch it; review it via the PR diff or skip it)`);
|
|
171
|
+
}
|
|
172
|
+
const text = body.encoding === "base64" ? Buffer.from(body.content, "base64").toString("utf8") : body.content;
|
|
173
|
+
return text.length > MAX_FILE_BYTES
|
|
174
|
+
? text.slice(0, MAX_FILE_BYTES) + `\n…[truncated, ${text.length - MAX_FILE_BYTES} more bytes]`
|
|
175
|
+
: text;
|
|
176
|
+
}
|
|
177
|
+
async pullDiff(coords, index) {
|
|
178
|
+
const { owner, repo } = coords;
|
|
179
|
+
// GitHub 在服务端静默截断 PR diff(300 文件 / 1 MB / 20k 行,文档行为;codex F2 定谳)——被截的
|
|
180
|
+
// 响应可能远小于本地帽,靠字节数判不出来。先取 PR 元数据拿 changed_files,与 diff 里实际出现的
|
|
181
|
+
// 文件数对账,少了就显式披露(上游截断与本地帽是两种截断,分开标)。
|
|
182
|
+
const meta = PullMetaWire.parse(await (await this.api(`/repos/${owner}/${repo}/pulls/${index}`)).json());
|
|
183
|
+
const res = await this.api(`/repos/${owner}/${repo}/pulls/${index}`, "application/vnd.github.v3.diff");
|
|
184
|
+
let text = await res.text();
|
|
185
|
+
const filesInDiff = (text.match(/^diff --git /gm) ?? []).length;
|
|
186
|
+
if (typeof meta.changed_files === "number" && filesInDiff < meta.changed_files) {
|
|
187
|
+
text += `\n…[upstream truncated: this diff covers ${filesInDiff} of ${meta.changed_files} changed files — GitHub caps PR diffs (300 files / 1 MB); review the remaining files individually]`;
|
|
188
|
+
}
|
|
189
|
+
return text.length > MAX_FILE_BYTES * 4 ? text.slice(0, MAX_FILE_BYTES * 4) + "\n…[diff truncated]" : text;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
78
192
|
/** Build the read-only repo tool set bound to one repo (cheap per-task closure; the client is shared). */
|
|
79
193
|
export function repoToolsFor(client, coords) {
|
|
80
194
|
const ref = (r) => (r ? Promise.resolve(r) : client.defaultBranch(coords));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type WebFetchConfig, type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
|
|
2
2
|
import type { Metrics } from "../observability/metrics.js";
|
|
3
3
|
import type { Logger } from "../observability/logger.js";
|
|
4
|
-
import {
|
|
4
|
+
import { type RepoReadClient } from "./repo-tools.js";
|
|
5
5
|
import { type LoadedSkill } from "./skills.js";
|
|
6
6
|
import { type ScenarioHands } from "./hands-lane.js";
|
|
7
7
|
/**
|
|
@@ -46,7 +46,7 @@ export interface ScenarioDeps {
|
|
|
46
46
|
handslessSubRunner: Runner;
|
|
47
47
|
model: string;
|
|
48
48
|
skills: LoadedSkill[];
|
|
49
|
-
repoClient?:
|
|
49
|
+
repoClient?: RepoReadClient;
|
|
50
50
|
/** Multi-tenant deployment (REQUIRE_PRINCIPAL). Gates the full-body `WebFetch` tool out of the
|
|
51
51
|
* default roster — core says a multi-tenant deployment MUST allowlist hosts (an injected/malicious tenant model
|
|
52
52
|
* could exfil via an arbitrary public URL; the SSRF floor only blocks internal). Single-user TOC keeps WebFetch. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CODE_AGENT_PROMPT, CODE_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT, assembleCodeTools, defaultTaskRegistry } from "@sema-agent/core";
|
|
2
2
|
import { HttpError } from "../security.js";
|
|
3
3
|
import { nowTool } from "./builtin-tools.js";
|
|
4
|
-
import {
|
|
4
|
+
import { parseRepo, repoToolsFor } from "./repo-tools.js";
|
|
5
5
|
import { createCouncilTool, clampRounds } from "./code-review-council.js";
|
|
6
6
|
import { stablePrompt } from "./prompt.js";
|
|
7
7
|
import { createTeamTool, teamCoordinatorPrompt, getTeam, teamNames } from "./team.js";
|
package/dist/config-types.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ElicitationThrottle } from "./elicitation.js";
|
|
|
12
12
|
import type { QuestionThrottle } from "./question.js";
|
|
13
13
|
import type { InfraCostRates } from "./observability/cost-taxonomy.js";
|
|
14
14
|
import type { Autonomy, CommandRule } from "./runtime-governance.js";
|
|
15
|
+
import type { GitApiKind } from "./git-api-kind.js";
|
|
15
16
|
/** A sema-registry MCP server resolved to a core spec (env-NAME refs already → real values) plus the
|
|
16
17
|
* scenarios it applies to (empty = all). `resolveSpec` filters by scenario and passes `spec` to core. */
|
|
17
18
|
export interface ScopedMcpServer {
|
|
@@ -966,6 +967,10 @@ export interface ServiceConfigFlat {
|
|
|
966
967
|
gitApiBaseUrl?: string;
|
|
967
968
|
/** Read-only Git host token (server-side only; never reaches the model). */
|
|
968
969
|
gitApiToken?: string;
|
|
970
|
+
/** Git host API dialect for the repo tools (env GIT_API_KIND, closed set gitea|github; default gitea).
|
|
971
|
+
* "github" = api.github.com or GHE `/api/v3` root — the Gitea client's `/api/v1` + `.diff` shapes
|
|
972
|
+
* 404 there (P1-2: our own repos live on GitHub and were unreviewable). Unknown word = refuse boot. */
|
|
973
|
+
gitApiKind: GitApiKind;
|
|
969
974
|
/** S3-TOB 设计 §1.3:memory 持久面方言(env MEMORY_ENGINE_BACKEND,缺省 file)。boot-only。 */
|
|
970
975
|
memoryEngineBackend: "file" | "pg" | "tidb";
|
|
971
976
|
/** CC `workflowSizeGuideline` (advisory, injected into the Workflow tool card via
|
|
@@ -1017,7 +1022,7 @@ export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachme
|
|
|
1017
1022
|
/** 组:observability(可观测)。 */
|
|
1018
1023
|
export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
|
|
1019
1024
|
/** 组:integrations(外部集成)。`mcpServers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
|
|
1020
|
-
export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
|
|
1025
|
+
export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "gitApiKind" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
|
|
1021
1026
|
/** 九个组槽。每组恒在场(`loadConfig` / `attachConfigGroups` 装好才交出配置),故不可选——新代码写
|
|
1022
1027
|
* `config.modelPlane.model` 不需要 `?.`(可选组会把 `Model` 污染成 `Model | undefined`)。 */
|
|
1023
1028
|
export interface ServiceConfigGroups {
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js"; // d
|
|
|
7
7
|
import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
|
|
8
8
|
import { isV2ScopeKey } from "./memory-scope.js"; // A-002.4: v2 scope 前缀词表的单一属主(纯谓词,无反向依赖)
|
|
9
9
|
import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
|
|
10
|
+
import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js"; // P1-2: 方言词表叶模块(config 不得值引 capabilities 层)
|
|
10
11
|
function csv(name) {
|
|
11
12
|
return (process.env[name] ?? "")
|
|
12
13
|
.split(",")
|
|
@@ -1501,6 +1502,42 @@ function parseObservabilityDomain() {
|
|
|
1501
1502
|
: undefined,
|
|
1502
1503
|
};
|
|
1503
1504
|
}
|
|
1505
|
+
/** GIT_API_KIND:repo 工具的 Git-host 方言(闭集 gitea|github,缺省 gitea)。
|
|
1506
|
+
* 未知词=拒启(安全轴词表纪律);另一坏形也拒启:kind 缺省/gitea 而 BASEURL 指向 github.com——
|
|
1507
|
+
* Gitea 形状(`/api/v1` 前缀、`.diff` 后缀)对 GitHub 恒 404,与其在任务运行时逐调用 loud 404,
|
|
1508
|
+
* 不如启动即指路(#123 FLEET_ADVERTISE_ADDRESS 坏形响亮拒同族)。 */
|
|
1509
|
+
function parseGitApiKind(raw, baseUrl) {
|
|
1510
|
+
const kind = raw ?? "gitea";
|
|
1511
|
+
if (!isGitApiKind(kind)) {
|
|
1512
|
+
throw new Error(`GIT_API_KIND="${raw}" is not a known Git host kind (expected one of: ${GIT_API_KINDS.join(", ")})`);
|
|
1513
|
+
}
|
|
1514
|
+
if (!baseUrl)
|
|
1515
|
+
return kind;
|
|
1516
|
+
// 配了就必须是合法 URL(USAGE 本就要求带 scheme 的完整 baseURL;坏 URL 此前静默过门、逐调用 fetch
|
|
1517
|
+
// 才炸 —— 启动即拒;codex F3)。尾点主机(api.github.com.)DNS 等价,归一后再比对,防绕过。
|
|
1518
|
+
if (!URL.canParse(baseUrl)) {
|
|
1519
|
+
throw new Error(`GIT_API_BASEURL="${baseUrl}" is not a valid URL (expected e.g. https://git.example.com)`);
|
|
1520
|
+
}
|
|
1521
|
+
const u = new URL(baseUrl);
|
|
1522
|
+
const host = u.hostname.replace(/\.$/, "").toLowerCase();
|
|
1523
|
+
const githubFamily = host === "github.com" || host === "api.github.com" || host.endsWith(".github.com");
|
|
1524
|
+
if (kind === "gitea" && githubFamily) {
|
|
1525
|
+
throw new Error(`GIT_API_BASEURL points at ${host} but GIT_API_KIND is "gitea" — the Gitea API shapes 404 on GitHub. Set GIT_API_KIND=github (base url should be https://api.github.com, or your GHE /api/v3 root).`);
|
|
1526
|
+
}
|
|
1527
|
+
if (kind === "github") {
|
|
1528
|
+
// 对称校验(codex F3):web 域名/带 path 的 api 根都会让每个仓操作 404,而客户端照建、场景照宣可用。
|
|
1529
|
+
if (githubFamily && host !== "api.github.com") {
|
|
1530
|
+
throw new Error(`GIT_API_BASEURL host ${host} is the GitHub WEB host — the API root is https://api.github.com (a repo page URL is not an API base).`);
|
|
1531
|
+
}
|
|
1532
|
+
if (host === "api.github.com" && u.pathname !== "/" && u.pathname !== "") {
|
|
1533
|
+
throw new Error(`GIT_API_BASEURL for github.com must be exactly https://api.github.com (no path; got path "${u.pathname}"). GHE uses https://<ghe-host>/api/v3.`);
|
|
1534
|
+
}
|
|
1535
|
+
if (u.search || u.hash) {
|
|
1536
|
+
throw new Error(`GIT_API_BASEURL must not carry a query or fragment (got "${baseUrl}")`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return kind;
|
|
1540
|
+
}
|
|
1504
1541
|
/** 域:misc-integration(外部集成)—— plugins clone 白名单、git/OA 面、场景与 skill 目录、sema-registry 接入。 */
|
|
1505
1542
|
function parseIntegrationsDomain() {
|
|
1506
1543
|
return {
|
|
@@ -1509,6 +1546,7 @@ function parseIntegrationsDomain() {
|
|
|
1509
1546
|
configBootFetchBudgetMs: Math.max(0, numEnv("CONFIG_BOOT_FETCH_BUDGET_MS", "1500")), // boot 首拉抢答窗;过窗转后台补齐(clay 2026-07-17:本地 5s 启动=黑洞中心同步等待)
|
|
1510
1547
|
gitApiBaseUrl: process.env.GIT_API_BASEURL,
|
|
1511
1548
|
gitApiToken: process.env.GIT_API_TOKEN,
|
|
1549
|
+
gitApiKind: parseGitApiKind(process.env.GIT_API_KIND, process.env.GIT_API_BASEURL),
|
|
1512
1550
|
// BEHAVIOR CHANGE([891] clay 硬裁定):出厂缺省场景 default→code(CC 编码 persona 蒸馏版)——
|
|
1513
1551
|
// 编码产品出厂态对标 CC 出厂态,通用域中性 persona 用 DEFAULT_SCENARIO=default 显式选回。
|
|
1514
1552
|
defaultScenario: env("DEFAULT_SCENARIO", "code"),
|
|
@@ -1570,7 +1608,7 @@ const LIMITS_HTTP_GROUP_KEYS = [
|
|
|
1570
1608
|
];
|
|
1571
1609
|
const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
|
|
1572
1610
|
const INTEGRATIONS_GROUP_KEYS = [
|
|
1573
|
-
"pluginsAllowHosts", "mcpServers", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "defaultScenario",
|
|
1611
|
+
"pluginsAllowHosts", "mcpServers", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "gitApiKind", "defaultScenario",
|
|
1574
1612
|
"skillsDir", "configCenter", "configProvider", "configLocalDir",
|
|
1575
1613
|
];
|
|
1576
1614
|
/** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
|
|
@@ -43,7 +43,7 @@ export declare const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20;
|
|
|
43
43
|
* 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有
|
|
44
44
|
* 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然),
|
|
45
45
|
* 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条
|
|
46
|
-
* 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `
|
|
46
|
+
* 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at_ms DESC`
|
|
47
47
|
* 的窄查询(要过真双库门,已作移交项记在发车说明里)。
|
|
48
48
|
*
|
|
49
49
|
* 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会
|
|
@@ -44,7 +44,7 @@ export const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20;
|
|
|
44
44
|
* 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有
|
|
45
45
|
* 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然),
|
|
46
46
|
* 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条
|
|
47
|
-
* 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `
|
|
47
|
+
* 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at_ms DESC`
|
|
48
48
|
* 的窄查询(要过真双库门,已作移交项记在发车说明里)。
|
|
49
49
|
*
|
|
50
50
|
* 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Git-host API dialect word table (P1-2). Leaf module — the single owner shared by the config layer
|
|
2
|
+
* (env parse/validation) and `capabilities/repo-tools` (the two client implementations); config must not
|
|
3
|
+
* value-import the capabilities layer (module-cycle gate §G), so the table lives below both. */
|
|
4
|
+
export declare const GIT_API_KINDS: readonly ["gitea", "github"];
|
|
5
|
+
export type GitApiKind = (typeof GIT_API_KINDS)[number];
|
|
6
|
+
export declare function isGitApiKind(v: unknown): v is GitApiKind;
|
|
7
|
+
//# sourceMappingURL=git-api-kind.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Git-host API dialect word table (P1-2). Leaf module — the single owner shared by the config layer
|
|
2
|
+
* (env parse/validation) and `capabilities/repo-tools` (the two client implementations); config must not
|
|
3
|
+
* value-import the capabilities layer (module-cycle gate §G), so the table lives below both. */
|
|
4
|
+
export const GIT_API_KINDS = ["gitea", "github"];
|
|
5
|
+
export function isGitApiKind(v) {
|
|
6
|
+
return typeof v === "string" && GIT_API_KINDS.includes(v);
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=git-api-kind.js.map
|
|
@@ -178,9 +178,9 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
178
178
|
};
|
|
179
179
|
}))
|
|
180
180
|
// severity DESC, then **explicitly** oldest-first within a tier ([2027] 第五节欠单②,红先绿后:
|
|
181
|
-
// test/assistant-triage-ordering.test.ts)。旧姿势只比 severity,靠「listByScope 是
|
|
181
|
+
// test/assistant-triage-ordering.test.ts)。旧姿势只比 severity,靠「listByScope 是 created_at_ms ASC
|
|
182
182
|
// + Array#sort 稳定」来兑现 §2 那句 oldest-first —— 而那个前提**只在本仓的 SQL 后端成立**
|
|
183
|
-
// (checkpoint-store-sql.ts 的 `ORDER BY
|
|
183
|
+
// (checkpoint-store-sql.ts 的 `ORDER BY created_at_ms ASC`):core 的 `CheckpointStore.listByScope` 接口
|
|
184
184
|
// 零顺序声明,InMemory/File 两个实现直接遍历 Map(LOCAL 车道包的正是 File store)⇒ 那句承诺过去是
|
|
185
185
|
// **后端相关**的。比较器自带兜底后它与 store 顺序无关。形与 listPending 的同族 sort 逐字一致
|
|
186
186
|
// (checkpoint-store-sql.ts `|| a.createdAt - b.createdAt`)。createdAt 缺席(pre-1.116 投影的老行)
|
package/dist/http/server.js
CHANGED
|
@@ -1406,8 +1406,8 @@ export function createHttpServer(rawDeps) {
|
|
|
1406
1406
|
// escaping HttpError here was (a) attributed to the OPERATOR's /decide request as a bare 400/403 — undiagnosable
|
|
1407
1407
|
// from that contract — and (b) swallowed by the D-D SLA deny-sweep's catch, silently failing EVERY tick. Fold it
|
|
1408
1408
|
// into a typed 409 result instead: the operator sees "this parked task is blocked by a policy change" (retry
|
|
1409
|
-
// after the policy is restored, or let
|
|
1410
|
-
// tick,
|
|
1409
|
+
// after the policy is restored, or let terminal_at_ms abort it); the sweep skips the row this tick (retried next
|
|
1410
|
+
// tick, terminal_at_ms backstop — its documented per-call-failure semantics).
|
|
1411
1411
|
let spec;
|
|
1412
1412
|
try {
|
|
1413
1413
|
spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
@@ -2651,7 +2651,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2651
2651
|
* (graceful; vs the abort the reaper's expire() gives resource_limit/needs_review). Reuses resumeCheckpoint,
|
|
2652
2652
|
* so the markResuming CAS makes it idempotent across replicas (one replica wins each resume) and the parked
|
|
2653
2653
|
* run row is driven correctly. A per-call failure (e.g. a redeployed scenario) is swallowed → the row stays
|
|
2654
|
-
* pending and is retried next tick, with the
|
|
2654
|
+
* pending and is retried next tick, with the terminal_at_ms backstop as the eventual abort if the deny never
|
|
2655
2655
|
* succeeds. Bounded per tick by the store query's LIMIT. Wired into main.ts's reaper.
|
|
2656
2656
|
*/
|
|
2657
2657
|
async function denyExpiredApprovals(now) {
|
|
@@ -2659,7 +2659,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2659
2659
|
if (!cs)
|
|
2660
2660
|
return;
|
|
2661
2661
|
const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
|
|
2662
|
-
// [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→
|
|
2662
|
+
// [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at_ms 窗内
|
|
2663
2663
|
// 每行每 tick 一条 warn 的噪声源)——其收割属 reaper 的 expire+reconcileParkedAgents 车道。判别在
|
|
2664
2664
|
// resume 尝试之前做(省掉注定 409 的整条 ctx/resolveSpec 重建尝试;代价=每过期行两次店读,过期集
|
|
2665
2665
|
// 本就被店查询 LIMIT 界住);命中聚合为单条 info 留痕。判别自身故障 ⇒ 按非 parked 处理(保留 warn,
|
|
@@ -2684,7 +2684,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2684
2684
|
for (const { sessionId } of expired) {
|
|
2685
2685
|
// 对抗评审 2026-07-11: log the swallow — a per-tick failure retried forever (e.g. a policy change now folded
|
|
2686
2686
|
// into a 409 by resumeCheckpoint, or a redeployed scenario) was fully silent; the row sat pending to its
|
|
2687
|
-
//
|
|
2687
|
+
// terminal_at_ms with zero operator-visible signal. Behavior unchanged (skip + retry next tick), now diagnosable.
|
|
2688
2688
|
if (await isParkedOwned(sessionId)) {
|
|
2689
2689
|
parkedSkipped += 1;
|
|
2690
2690
|
continue;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export { buildSessionAudit, type SessionAudit } from "./audit.js";
|
|
|
12
12
|
export { createLogger, type Logger, type LogLevel } from "./observability/logger.js";
|
|
13
13
|
export { Metrics, createMetrics, type Labels } from "./observability/metrics.js";
|
|
14
14
|
export { RateLimiter, type RateDecision } from "./observability/rate-limit.js";
|
|
15
|
-
export { GiteaClient, repoToolsFor, parseRepo, type RepoCoords } from "./capabilities/repo-tools.js";
|
|
15
|
+
export { GiteaClient, GitHubClient, createRepoClient, GIT_API_KINDS, isGitApiKind, repoToolsFor, parseRepo, type GitApiKind, type RepoCoords, type RepoReadClient } from "./capabilities/repo-tools.js";
|
|
16
16
|
export { nowTool } from "./capabilities/builtin-tools.js";
|
|
17
17
|
export { createCouncilTool } from "./capabilities/code-review-council.js";
|
|
18
18
|
export { loadSkills, skillsForScenario, type LoadedSkill } from "./capabilities/skills.js";
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ export { buildSessionAudit } from "./audit.js";
|
|
|
17
17
|
export { createLogger } from "./observability/logger.js";
|
|
18
18
|
export { Metrics, createMetrics } from "./observability/metrics.js";
|
|
19
19
|
export { RateLimiter } from "./observability/rate-limit.js";
|
|
20
|
-
export { GiteaClient, repoToolsFor, parseRepo } from "./capabilities/repo-tools.js";
|
|
20
|
+
export { GiteaClient, GitHubClient, createRepoClient, GIT_API_KINDS, isGitApiKind, repoToolsFor, parseRepo } from "./capabilities/repo-tools.js";
|
|
21
21
|
export { nowTool } from "./capabilities/builtin-tools.js";
|
|
22
22
|
export { createCouncilTool } from "./capabilities/code-review-council.js";
|
|
23
23
|
export { loadSkills, skillsForScenario } from "./capabilities/skills.js";
|
package/dist/main.js
CHANGED
|
@@ -16,7 +16,7 @@ import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadO
|
|
|
16
16
|
import { createAuthorizer, encodeCheckpointScope } from "./security.js";
|
|
17
17
|
import { assertGateIntentServiceable, hasOperatorGateIntent } from "./approval.js";
|
|
18
18
|
import { loadSkills } from "./capabilities/skills.js";
|
|
19
|
-
import {
|
|
19
|
+
import { createRepoClient } from "./capabilities/repo-tools.js";
|
|
20
20
|
import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
|
|
21
21
|
import { createHandsLaneRegistry, pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
|
|
22
22
|
import { createLogger } from "./observability/logger.js";
|
|
@@ -570,7 +570,7 @@ async function main() {
|
|
|
570
570
|
// 解析搬到 boot/config-center.ts(逐字)。⚠️ 位置即契约:loadSkills 之后、buildScenarios 之前 ——
|
|
571
571
|
// LKG 落盘点必须晚于 skill 正文装载(F7/codex R26),plugins 让位判据要求 plugins 晚于 center 直发 skills。
|
|
572
572
|
skills = await configCenter.applyCenterCapabilities(skills);
|
|
573
|
-
const repoClient = config.gitApiBaseUrl ?
|
|
573
|
+
const repoClient = config.gitApiBaseUrl ? createRepoClient(config.gitApiKind, config.gitApiBaseUrl, config.gitApiToken) : undefined;
|
|
574
574
|
// CC-parity: deployment-injected WebSearch backend (the leg core leaves open). Absent WEB_SEARCH_PROVIDER →
|
|
575
575
|
// undefined → the default scenario doesn't assemble the WebSearch tool. The API key stays in the backend closure.
|
|
576
576
|
const webSearchCfg = webSearchConfigFromEnv();
|
|
@@ -4,7 +4,7 @@ import { type Checkpoint, type CheckpointGate, type CheckpointState, type Checkp
|
|
|
4
4
|
import { type SqlDriver } from "./sql-driver.js";
|
|
5
5
|
/**
|
|
6
6
|
* design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
|
|
7
|
-
* checkpoint's lifetime, stamped at put() into `
|
|
7
|
+
* checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
|
|
8
8
|
* SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
|
|
9
9
|
* with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
|
|
10
10
|
* forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
|
|
@@ -13,9 +13,9 @@ import { type SqlDriver } from "./sql-driver.js";
|
|
|
13
13
|
*/
|
|
14
14
|
export declare const TERMINAL_BACKSTOP_MS: number;
|
|
15
15
|
/**
|
|
16
|
-
* design/80 D-D (adversarial fix): the crash-safe `
|
|
17
|
-
* `deadline`, never AT it. `
|
|
18
|
-
* operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's
|
|
16
|
+
* design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
|
|
17
|
+
* `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
|
|
18
|
+
* operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
|
|
19
19
|
* has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
|
|
20
20
|
* wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
|
|
21
21
|
* at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
|
|
@@ -252,8 +252,8 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
252
252
|
/**
|
|
253
253
|
* GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
|
|
254
254
|
* sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
|
|
255
|
-
* Called with `cutoff = Date.now()` (deadline/
|
|
256
|
-
* whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `
|
|
255
|
+
* Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
|
|
256
|
+
* whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
|
|
257
257
|
* the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
|
|
258
258
|
*
|
|
259
259
|
* design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
|
|
@@ -262,7 +262,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
262
262
|
* AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
|
|
263
263
|
* incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
|
|
264
264
|
* (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
|
|
265
|
-
* stay on the expire path. The
|
|
265
|
+
* stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
|
|
266
266
|
* whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
|
|
267
267
|
* the deadline, so it never races the deny-sweep at the deadline instant).
|
|
268
268
|
*/
|
|
@@ -291,7 +291,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
291
291
|
* (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
|
|
292
292
|
* core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
|
|
293
293
|
* {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
|
|
294
|
-
*
|
|
294
|
+
* created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
|
|
295
295
|
* is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
|
|
296
296
|
*
|
|
297
297
|
* LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
|
|
@@ -309,7 +309,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
309
309
|
*
|
|
310
310
|
* 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
|
|
311
311
|
* 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
|
|
312
|
-
* 谓词精确查——`(scope, session_id, tool_call_id,
|
|
312
|
+
* 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
|
|
313
313
|
* 一次全量返回,结构上没有分页假阴性。
|
|
314
314
|
*
|
|
315
315
|
* 三条口径,逐条都是判据:
|
|
@@ -37,7 +37,7 @@ import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.
|
|
|
37
37
|
const MAX_TOOL_INPUT_CHARS = 8192;
|
|
38
38
|
/**
|
|
39
39
|
* design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
|
|
40
|
-
* checkpoint's lifetime, stamped at put() into `
|
|
40
|
+
* checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
|
|
41
41
|
* SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
|
|
42
42
|
* with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
|
|
43
43
|
* forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
|
|
@@ -46,15 +46,15 @@ const MAX_TOOL_INPUT_CHARS = 8192;
|
|
|
46
46
|
*/
|
|
47
47
|
export const TERMINAL_BACKSTOP_MS = Math.max(60_000, Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) || 30 * 86_400_000);
|
|
48
48
|
/**
|
|
49
|
-
* design/80 D-D (adversarial fix): the crash-safe `
|
|
50
|
-
* `deadline`, never AT it. `
|
|
51
|
-
* operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's
|
|
49
|
+
* design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
|
|
50
|
+
* `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
|
|
51
|
+
* operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
|
|
52
52
|
* has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
|
|
53
53
|
* wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
|
|
54
54
|
* at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
|
|
55
55
|
* it never meaningfully delays the eventual crash-safe GC.
|
|
56
56
|
*/
|
|
57
|
-
export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time
|
|
57
|
+
export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at_ms derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
|
|
58
58
|
/**
|
|
59
59
|
* The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a
|
|
60
60
|
* resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic
|
|
@@ -303,8 +303,8 @@ export class SqlCheckpointStore {
|
|
|
303
303
|
// payload without an N+1 trace.turns fetch. Stored as a stringified JSON column value.
|
|
304
304
|
const toolInput = boundedToolInput(pa?.args);
|
|
305
305
|
try {
|
|
306
|
-
await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline,
|
|
307
|
-
"VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline,
|
|
306
|
+
await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
|
|
307
|
+
"VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
|
|
308
308
|
"VALUES ($1,$2,$3,$4,'pending',$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)"), [
|
|
309
309
|
token,
|
|
310
310
|
cp.scope,
|
|
@@ -316,7 +316,7 @@ export class SqlCheckpointStore {
|
|
|
316
316
|
this.json(cp, "checkpoint"),
|
|
317
317
|
cp.deadline ?? null,
|
|
318
318
|
cp.createdAt,
|
|
319
|
-
Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the
|
|
319
|
+
Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at_ms-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
|
|
320
320
|
cp.gate?.kind ?? null, // D-D SLA split: human/irreversible_ask deadline → resolve-deny; others → expire
|
|
321
321
|
pa?.boundInputHash ?? null, // D-1: the opaque hash the portal must echo on /decide (surfaced via listPending so the TOCTOU binding is reachable)
|
|
322
322
|
((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate), // riskDescriptor inbox: stamp core's INERT descriptor for triage-sort
|
|
@@ -413,8 +413,8 @@ export class SqlCheckpointStore {
|
|
|
413
413
|
const params = [this.json(outcome, "checkpoint outcome"), Date.now(), token, scope];
|
|
414
414
|
if (expect)
|
|
415
415
|
params.push(expect.rev);
|
|
416
|
-
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?,
|
|
417
|
-
(expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb,
|
|
416
|
+
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at_ms = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
|
|
417
|
+
(expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at_ms = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
|
|
418
418
|
(expect ? " AND rev = $5" : "")), params);
|
|
419
419
|
return res.affected === 1;
|
|
420
420
|
}
|
|
@@ -508,19 +508,19 @@ export class SqlCheckpointStore {
|
|
|
508
508
|
* Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome.
|
|
509
509
|
*/
|
|
510
510
|
async expire(token, scope) {
|
|
511
|
-
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired',
|
|
511
|
+
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
|
|
512
512
|
return res.affected === 1;
|
|
513
513
|
}
|
|
514
514
|
/** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */
|
|
515
515
|
async reap(scope, cutoff) {
|
|
516
|
-
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired',
|
|
516
|
+
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
|
|
517
517
|
return res.affected;
|
|
518
518
|
}
|
|
519
519
|
/**
|
|
520
520
|
* GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
|
|
521
521
|
* sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
|
|
522
|
-
* Called with `cutoff = Date.now()` (deadline/
|
|
523
|
-
* whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `
|
|
522
|
+
* Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
|
|
523
|
+
* whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
|
|
524
524
|
* the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
|
|
525
525
|
*
|
|
526
526
|
* design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
|
|
@@ -529,14 +529,14 @@ export class SqlCheckpointStore {
|
|
|
529
529
|
* AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
|
|
530
530
|
* incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
|
|
531
531
|
* (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
|
|
532
|
-
* stay on the expire path. The
|
|
532
|
+
* stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
|
|
533
533
|
* whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
|
|
534
534
|
* the deadline, so it never races the deny-sweep at the deadline instant).
|
|
535
535
|
*/
|
|
536
536
|
async reapExpired(cutoff) {
|
|
537
|
-
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired',
|
|
538
|
-
`WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (
|
|
539
|
-
`WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (
|
|
537
|
+
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? " +
|
|
538
|
+
`WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 " +
|
|
539
|
+
`WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= $3))`), [Date.now(), cutoff, cutoff]);
|
|
540
540
|
return res.affected;
|
|
541
541
|
}
|
|
542
542
|
/**
|
|
@@ -560,11 +560,11 @@ export class SqlCheckpointStore {
|
|
|
560
560
|
* joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
|
|
561
561
|
* session claim, so the join is live for every pending row; null only in pathological windows). */
|
|
562
562
|
async listPending(scope) {
|
|
563
|
-
const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.
|
|
563
|
+
const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
|
|
564
564
|
"FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
|
|
565
565
|
const { rows } = scope
|
|
566
|
-
? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.
|
|
567
|
-
: await this.db.query(`${base} ORDER BY c.
|
|
566
|
+
? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
|
|
567
|
+
: await this.db.query(`${base} ORDER BY c.created_at_ms ASC`);
|
|
568
568
|
const out = rows.map((r) => {
|
|
569
569
|
const toolCallId = r.tool_call_id ?? null;
|
|
570
570
|
const boundInputHash = r.bound_input_hash ?? null;
|
|
@@ -587,7 +587,7 @@ export class SqlCheckpointStore {
|
|
|
587
587
|
// Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
|
|
588
588
|
// (拒绝式)——直读即审阅面=执行面。
|
|
589
589
|
input: r.tool_input ?? null,
|
|
590
|
-
createdAt: Number(r.
|
|
590
|
+
createdAt: Number(r.created_at_ms),
|
|
591
591
|
deadline: r.deadline == null ? null : Number(r.deadline),
|
|
592
592
|
riskDescriptor: parseJson(r.risk_descriptor),
|
|
593
593
|
};
|
|
@@ -602,14 +602,14 @@ export class SqlCheckpointStore {
|
|
|
602
602
|
* (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
|
|
603
603
|
* core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
|
|
604
604
|
* {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
|
|
605
|
-
*
|
|
605
|
+
* created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
|
|
606
606
|
* is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
|
|
607
607
|
*
|
|
608
608
|
* LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
|
|
609
609
|
* ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates.
|
|
610
610
|
*/
|
|
611
611
|
async listByScope(scope) {
|
|
612
|
-
const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY
|
|
612
|
+
const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
|
|
613
613
|
const out = [];
|
|
614
614
|
for (const r of rows) {
|
|
615
615
|
// A corrupt/missing blob skips THAT row rather than crashing the whole scheduler view (review w16yqkkxv):
|
|
@@ -638,7 +638,7 @@ export class SqlCheckpointStore {
|
|
|
638
638
|
*
|
|
639
639
|
* 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
|
|
640
640
|
* 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
|
|
641
|
-
* 谓词精确查——`(scope, session_id, tool_call_id,
|
|
641
|
+
* 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
|
|
642
642
|
* 一次全量返回,结构上没有分页假阴性。
|
|
643
643
|
*
|
|
644
644
|
* 三条口径,逐条都是判据:
|
|
@@ -661,9 +661,9 @@ export class SqlCheckpointStore {
|
|
|
661
661
|
* 维,列命中行结构上也必须解 blob 才拿得到它。
|
|
662
662
|
*/
|
|
663
663
|
async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
|
|
664
|
-
const { rows } = await this.db.query(this.q("SELECT token, status,
|
|
665
|
-
"WHERE scope=? AND session_id=? AND
|
|
666
|
-
"WHERE scope=$1 AND session_id=$2 AND
|
|
664
|
+
const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
|
|
665
|
+
"WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
|
|
666
|
+
"WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
|
|
667
667
|
const out = [];
|
|
668
668
|
for (const r of rows) {
|
|
669
669
|
// `sourceTaskId: null` 是**所有 unparseable 臂的共同底**(读不出的行不许带出一个可用于身份比对的
|
|
@@ -671,7 +671,7 @@ export class SqlCheckpointStore {
|
|
|
671
671
|
const base = {
|
|
672
672
|
token: String(r.token),
|
|
673
673
|
status: String(r.status),
|
|
674
|
-
createdAtMs: Number(r.
|
|
674
|
+
createdAtMs: Number(r.created_at_ms),
|
|
675
675
|
boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
|
|
676
676
|
sourceTaskId: null,
|
|
677
677
|
};
|
|
@@ -84,7 +84,7 @@ export declare class LocalCheckpointStore {
|
|
|
84
84
|
* design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
|
|
85
85
|
* every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
|
|
86
86
|
* listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
|
|
87
|
-
* the
|
|
87
|
+
* the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
|
|
88
88
|
*/
|
|
89
89
|
reapExpired(cutoff: number): Promise<number>;
|
|
90
90
|
/** design/80 D-D SLA deny-sweep input — pending human/irreversible_ask gates past deadline (excl. AskUserQuestion). */
|
|
@@ -26,7 +26,7 @@ import { join } from "node:path";
|
|
|
26
26
|
import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
|
|
27
27
|
import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
|
|
28
28
|
import { boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
|
|
29
|
-
/** design/80 D-D read-time twin of the TiDB put-time `
|
|
29
|
+
/** design/80 D-D read-time twin of the TiDB put-time `terminal_at_ms` column (same formula — anti-drift). */
|
|
30
30
|
function terminalAtOf(cp) {
|
|
31
31
|
return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
|
|
32
32
|
}
|
|
@@ -286,7 +286,7 @@ export class LocalCheckpointStore {
|
|
|
286
286
|
* design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
|
|
287
287
|
* every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
|
|
288
288
|
* listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
|
|
289
|
-
* the
|
|
289
|
+
* the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
|
|
290
290
|
*/
|
|
291
291
|
async reapExpired(cutoff) {
|
|
292
292
|
let n = 0;
|
|
@@ -44,8 +44,8 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
|
|
|
44
44
|
const embeddingCol = vec?.pgvector ? `vector(${vec.dimensions})` : "jsonb";
|
|
45
45
|
await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.entry} (
|
|
46
46
|
id text COLLATE "C" PRIMARY KEY,
|
|
47
|
-
scope
|
|
48
|
-
slug
|
|
47
|
+
scope varchar(190) COLLATE "C" NOT NULL,
|
|
48
|
+
slug varchar(512) COLLATE "C" NOT NULL,
|
|
49
49
|
frontmatter jsonb NOT NULL,
|
|
50
50
|
body text COLLATE "C" NOT NULL,
|
|
51
51
|
rev text COLLATE "C" NOT NULL,
|
|
@@ -66,7 +66,7 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
|
|
|
66
66
|
// suffix. NOT wrapped in try/catch — if this constraint cannot be created, the backend is unsafe.
|
|
67
67
|
await query(`CREATE UNIQUE INDEX IF NOT EXISTS uq_${PG_MEMORY_ENGINE_TABLES.entry}_scope_slug ON ${PG_MEMORY_ENGINE_TABLES.entry} (scope, slug)`);
|
|
68
68
|
await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.cursor} (
|
|
69
|
-
scope
|
|
69
|
+
scope varchar(190) COLLATE "C" PRIMARY KEY,
|
|
70
70
|
cursor text COLLATE "C" NOT NULL
|
|
71
71
|
)`);
|
|
72
72
|
}
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -96,11 +96,11 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
96
96
|
checkpoint JSONB NOT NULL,
|
|
97
97
|
outcome JSONB,
|
|
98
98
|
deadline BIGINT,
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
created_at_ms BIGINT NOT NULL,
|
|
100
|
+
decided_at_ms BIGINT,
|
|
101
101
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
102
102
|
reopen_reason VARCHAR(32) COLLATE "C",
|
|
103
|
-
|
|
103
|
+
terminal_at_ms BIGINT,
|
|
104
104
|
gate_kind VARCHAR(32) COLLATE "C",
|
|
105
105
|
bound_input_hash VARCHAR(190) COLLATE "C",
|
|
106
106
|
-- pending_steer / pending_steer_queue / pending_steer_rev:队列化后的 durable steering(core 5.14.0
|
|
@@ -222,7 +222,7 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
222
222
|
source_run_id VARCHAR(191) COLLATE "C" NOT NULL,
|
|
223
223
|
scope VARCHAR(190) COLLATE "C" NOT NULL,
|
|
224
224
|
new_run_id VARCHAR(191) COLLATE "C" NOT NULL,
|
|
225
|
-
|
|
225
|
+
claimed_at_ms BIGINT NOT NULL,
|
|
226
226
|
PRIMARY KEY (source_run_id, scope)
|
|
227
227
|
)`,
|
|
228
228
|
// P1 (fleet failover): WorkflowRunStore + completion-inbox PG twins (design doc in workflow-run-store-sql.ts).
|
|
@@ -234,7 +234,7 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
234
234
|
run TEXT COLLATE "C" NOT NULL,
|
|
235
235
|
rev INTEGER NOT NULL DEFAULT 0,
|
|
236
236
|
created_at_ms BIGINT NOT NULL,
|
|
237
|
-
|
|
237
|
+
ended_at_ms BIGINT,
|
|
238
238
|
PRIMARY KEY (id)
|
|
239
239
|
)`,
|
|
240
240
|
`CREATE INDEX IF NOT EXISTS idx_wfrun_scope_created ON workflow_run (scope, created_at_ms)`,
|
|
@@ -279,8 +279,8 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
279
279
|
acked SMALLINT NOT NULL DEFAULT 0,
|
|
280
280
|
source_task_id VARCHAR(191) COLLATE "C",
|
|
281
281
|
principal VARCHAR(190) COLLATE "C",
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
created_at_ms BIGINT NOT NULL,
|
|
283
|
+
acked_at_ms BIGINT,
|
|
284
284
|
PRIMARY KEY (run_id)
|
|
285
285
|
)`,
|
|
286
286
|
`CREATE INDEX IF NOT EXISTS idx_wfnotify_acked ON workflow_notify_journal (acked)`,
|
|
@@ -263,11 +263,11 @@ export declare class SqlRunStore {
|
|
|
263
263
|
*/
|
|
264
264
|
reapSuspended(olderThanMs: number): Promise<number>;
|
|
265
265
|
/**
|
|
266
|
-
* design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `
|
|
266
|
+
* design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
|
|
267
267
|
* suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
|
|
268
|
-
* absolute `
|
|
268
|
+
* absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
|
|
269
269
|
* approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
|
|
270
|
-
* `
|
|
270
|
+
* `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
|
|
271
271
|
* either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
|
|
272
272
|
* windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
|
|
273
273
|
* checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
|
|
@@ -563,11 +563,11 @@ export class SqlRunStore {
|
|
|
563
563
|
return reaped;
|
|
564
564
|
}
|
|
565
565
|
/**
|
|
566
|
-
* design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `
|
|
566
|
+
* design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
|
|
567
567
|
* suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
|
|
568
|
-
* absolute `
|
|
568
|
+
* absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
|
|
569
569
|
* approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
|
|
570
|
-
* `
|
|
570
|
+
* `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
|
|
571
571
|
* either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
|
|
572
572
|
* windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
|
|
573
573
|
* checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
|
|
@@ -233,8 +233,8 @@ export const SCHEMA_STATEMENTS = [
|
|
|
233
233
|
checkpoint JSON NOT NULL,
|
|
234
234
|
outcome JSON NULL,
|
|
235
235
|
deadline BIGINT NULL,
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
created_at_ms BIGINT NOT NULL,
|
|
237
|
+
decided_at_ms BIGINT NULL,
|
|
238
238
|
-- rev (design/80 D-1): monotonic optimistic-concurrency counter bumped on every resolve/reopen, so a
|
|
239
239
|
-- resolve(expect) requires the rev the resume observed to still be live → fail-closed on a concurrent
|
|
240
240
|
-- resolve-reopen cycle (core → checkpoint.reopened_concurrently).
|
|
@@ -244,10 +244,10 @@ export const SCHEMA_STATEMENTS = [
|
|
|
244
244
|
-- tool_unavailable (a fresh decision is allowed). Drives core's reopen-revote validation.
|
|
245
245
|
-- NULL = NEVER REOPENED (the first resume is unconstrained).
|
|
246
246
|
reopen_reason VARCHAR(32) NULL,
|
|
247
|
-
--
|
|
247
|
+
-- terminal_at_ms: crash-safe ABSOLUTE lifetime backstop (design/80 §3 inv#3), distinct from the per-approval
|
|
248
248
|
-- deadline, stamped at put(). NULL on PRE-MIGRATION rows — those keep their DEADLINE-BASED expiry (the old
|
|
249
249
|
-- path); the backstop only covers rows suspended after the column existed.
|
|
250
|
-
|
|
250
|
+
terminal_at_ms BIGINT NULL,
|
|
251
251
|
-- gate_kind (design/80 D-D, SLA-timer): the CheckpointGate.kind, stamped at put(), so the SLA sweep splits by
|
|
252
252
|
-- kind WITHOUT parsing the JSON blob per row — human/irreversible_ask past deadline are resolve-DENIED (the
|
|
253
253
|
-- model continues with a denial), resource_limit/needs_review are abandonment-TTL → expire().
|
|
@@ -430,7 +430,7 @@ export const SCHEMA_STATEMENTS = [
|
|
|
430
430
|
source_run_id VARCHAR(191) NOT NULL,
|
|
431
431
|
scope VARCHAR(190) NOT NULL,
|
|
432
432
|
new_run_id VARCHAR(191) NOT NULL,
|
|
433
|
-
|
|
433
|
+
claimed_at_ms BIGINT NOT NULL,
|
|
434
434
|
PRIMARY KEY (source_run_id, scope)
|
|
435
435
|
) COLLATE utf8mb4_bin`,
|
|
436
436
|
// P1 (fleet failover, 2026-07-05): durable cross-replica WorkflowRunStore twin — one row per run, the full
|
|
@@ -443,7 +443,7 @@ export const SCHEMA_STATEMENTS = [
|
|
|
443
443
|
run MEDIUMTEXT NOT NULL,
|
|
444
444
|
rev INT NOT NULL DEFAULT 0,
|
|
445
445
|
created_at_ms BIGINT NOT NULL,
|
|
446
|
-
|
|
446
|
+
ended_at_ms BIGINT NULL,
|
|
447
447
|
PRIMARY KEY (id),
|
|
448
448
|
KEY idx_wfrun_scope_created (scope, created_at_ms),
|
|
449
449
|
KEY idx_wfrun_scope_status (scope, status)
|
|
@@ -494,8 +494,8 @@ export const SCHEMA_STATEMENTS = [
|
|
|
494
494
|
acked TINYINT(1) NOT NULL DEFAULT 0,
|
|
495
495
|
source_task_id VARCHAR(191) NULL,
|
|
496
496
|
principal VARCHAR(190) NULL,
|
|
497
|
-
|
|
498
|
-
|
|
497
|
+
created_at_ms BIGINT NOT NULL,
|
|
498
|
+
acked_at_ms BIGINT NULL,
|
|
499
499
|
PRIMARY KEY (run_id),
|
|
500
500
|
KEY idx_wfnotify_acked (acked)
|
|
501
501
|
) COLLATE utf8mb4_bin`,
|
|
@@ -41,7 +41,7 @@ import { type SqlDriver } from "./sql-driver.js";
|
|
|
41
41
|
/** Dual-dialect durable WorkflowJournalStore. See the file header for the dialect-delta ledger. */
|
|
42
42
|
/** RB-242 租约旋钮。TTL 只是**崩溃兜底**(engine 终态 finally 显式释放;[1981]/[1984] 两层分工:
|
|
43
43
|
* engine 崩了没释放 ⇒ 陈旧 claim 可被接管)。
|
|
44
|
-
* 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(
|
|
44
|
+
* 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(claimed_at_ms 在授予时刻定格,
|
|
45
45
|
* 运行期间不刷新),所以 TTL 必须盖过最长合法 run 时长:取小了(我初版 15min)会在一次 >TTL 的活跑
|
|
46
46
|
* 中把 claim 判陈旧、放另一副本进来接管——恰是本缝要防的双跑。要更短的接管等待,先给 engine 半场
|
|
47
47
|
* 加心跳刷新,再谈调小。 */
|
|
@@ -89,8 +89,8 @@ export declare class SqlWorkflowJournalStore implements WorkflowJournalStore {
|
|
|
89
89
|
* 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
|
|
90
90
|
* 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
|
|
91
91
|
* ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
|
|
92
|
-
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的
|
|
93
|
-
* ③ TTL 崩溃兜底接管:
|
|
92
|
+
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
|
|
93
|
+
* ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
|
|
94
94
|
* 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
|
|
95
95
|
* ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
|
|
96
96
|
resumeClaim(input: {
|
|
@@ -76,8 +76,8 @@ export class SqlWorkflowJournalStore {
|
|
|
76
76
|
* 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
|
|
77
77
|
* 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
|
|
78
78
|
* ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
|
|
79
|
-
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的
|
|
80
|
-
* ③ TTL 崩溃兜底接管:
|
|
79
|
+
* ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
|
|
80
|
+
* ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
|
|
81
81
|
* 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
|
|
82
82
|
* ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
|
|
83
83
|
async resumeClaim(input) {
|
|
@@ -85,20 +85,20 @@ export class SqlWorkflowJournalStore {
|
|
|
85
85
|
const ins = await this.db.query(this.q(
|
|
86
86
|
// INSERT IGNORE(非 ON DUP KEY 无操作形):TiDB 对「无变化的 DUP KEY UPDATE」affected 报 1
|
|
87
87
|
// (MySQL 报 0)——真双库跑出来的方言差;IGNORE 形两家都在冲突时报 0。
|
|
88
|
-
"INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id,
|
|
88
|
+
"INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
|
|
89
89
|
if (ins.affected === 1)
|
|
90
90
|
return { granted: true };
|
|
91
|
-
const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET
|
|
91
|
+
const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at_ms = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
|
|
92
92
|
if (refresh.affected >= 1)
|
|
93
93
|
return { granted: true };
|
|
94
|
-
const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?,
|
|
94
|
+
const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?, claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND claimed_at_ms < ?", "UPDATE workflow_resume_claim SET new_run_id = $1, claimed_at_ms = $2 WHERE source_run_id = $3 AND scope = $4 AND claimed_at_ms < $5"), [input.newRunId, now, input.sourceRunId, input.scope, now - this.resumeClaimTtlMs]);
|
|
95
95
|
if (takeover.affected >= 1)
|
|
96
96
|
return { granted: true };
|
|
97
97
|
const holder = await this.db.query(this.q("SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ?", "SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2"), [input.sourceRunId, input.scope]);
|
|
98
98
|
// 行在①-③间被释放的窄窗:holder 读空 ⇒ 如实返 denied 无 holder(engine 下一次重试会在①赢)。
|
|
99
99
|
const row = holder.rows[0];
|
|
100
100
|
const holderId = row?.new_run_id === undefined ? undefined : String(row.new_run_id);
|
|
101
|
-
// ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(
|
|
101
|
+
// ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at_ms 未变)会 affected=0
|
|
102
102
|
// 掉到这里;持有者==自己仍是 granted(幂等重入语义不依赖 affected 的方言细节)。
|
|
103
103
|
if (holderId === input.newRunId)
|
|
104
104
|
return { granted: true };
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ## WorkflowRunStore twin
|
|
14
14
|
* One row per run: the full `WorkflowRun` as a JSON blob + EXTRACTED columns for everything SQL needs to
|
|
15
|
-
* index/filter (scope, status, created_at_ms,
|
|
15
|
+
* index/filter (scope, status, created_at_ms, ended_at_ms) + an authoritative `rev` column (the OCC key — the
|
|
16
16
|
* blob's own `rev` is OVERLAID from the column on every read, so writes never have to know the bumped value
|
|
17
17
|
* up front). `update` is a single-statement CAS (`WHERE id AND scope [AND rev]`, `SET rev = rev + 1`);
|
|
18
18
|
* `listByScope` projects через core's SHARED `summarizeWorkflowRun` (anti-drift — identical to InMemory/File).
|
|
@@ -46,7 +46,7 @@ export class SqlWorkflowRunStore {
|
|
|
46
46
|
async put(id, run) {
|
|
47
47
|
const stored = { ...run, id, rev: run.rev ?? 0 }; // key authoritative + observed-rev base (InMemory parity)
|
|
48
48
|
try {
|
|
49
|
-
await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms,
|
|
49
|
+
await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
|
|
50
50
|
}
|
|
51
51
|
catch (e) {
|
|
52
52
|
// create-once error every backend throws (contract) — dup-key classification is dialect-specific:
|
|
@@ -83,7 +83,7 @@ export class SqlWorkflowRunStore {
|
|
|
83
83
|
return false; // even the skeleton is oversize — keep the prior revision
|
|
84
84
|
blob = slim;
|
|
85
85
|
}
|
|
86
|
-
const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?,
|
|
86
|
+
const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?, ended_at_ms = ? WHERE id = ? AND scope = ?${expect !== undefined ? " AND rev = ?" : ""}`, `UPDATE workflow_run SET run = $1, rev = rev + 1, status = $2, ended_at_ms = $3 WHERE id = $4 AND scope = $5${expect !== undefined ? " AND rev = $6" : ""}`), expect !== undefined
|
|
87
87
|
? [blob, run.status, run.endedAt ?? null, id, scope, expect.rev]
|
|
88
88
|
: [blob, run.status, run.endedAt ?? null, id, scope]);
|
|
89
89
|
return res.affected === 1; // rev always bumps → affected is a faithful CAS verdict
|
|
@@ -156,12 +156,12 @@ export class SqlWorkflowRunStore {
|
|
|
156
156
|
return 0; // retention is always explicit
|
|
157
157
|
// Cheap columns only; terminal-set + keep-N semantics computed in JS to match InMemory EXACTLY
|
|
158
158
|
// (isTerminalWorkflowStatus is core's — no status list duplicated into SQL).
|
|
159
|
-
const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms,
|
|
159
|
+
const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = ? ORDER BY created_at_ms DESC, id DESC", "SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = $1 ORDER BY created_at_ms DESC, id DESC"), [scope]);
|
|
160
160
|
const terminal = rows.filter((r) => isTerminalWorkflowStatus(String(r.status)));
|
|
161
161
|
const doomed = [];
|
|
162
162
|
for (let i = 0; i < terminal.length; i++) {
|
|
163
163
|
const r = terminal[i];
|
|
164
|
-
const anchor = r.
|
|
164
|
+
const anchor = r.ended_at_ms != null ? Number(r.ended_at_ms) : Number(r.created_at_ms);
|
|
165
165
|
const tooOld = opts.maxAgeMs !== undefined && anchor < now - opts.maxAgeMs;
|
|
166
166
|
const overKeep = opts.keep !== undefined && i >= opts.keep;
|
|
167
167
|
if (tooOld || overKeep)
|
|
@@ -311,31 +311,31 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
311
311
|
async record(input) {
|
|
312
312
|
// Idempotent-on-runId (a second record for the same run — incl. an acked one — is a no-op): TiDB
|
|
313
313
|
// `INSERT IGNORE` vs PG `ON CONFLICT (run_id) DO NOTHING`.
|
|
314
|
-
await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal,
|
|
314
|
+
await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES (?,?,0,?,?,?)", "INSERT INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (run_id) DO NOTHING"), [input.runId, input.scope, input.sourceTaskId ?? null, input.principal ?? null, input.createdAt]);
|
|
315
315
|
}
|
|
316
316
|
async ack(runId, ackedAt) {
|
|
317
|
-
await this.db.query(this.q("UPDATE workflow_notify_journal SET acked = 1,
|
|
317
|
+
await this.db.query(this.q("UPDATE workflow_notify_journal SET acked = 1, acked_at_ms = ? WHERE run_id = ? AND acked = 0", "UPDATE workflow_notify_journal SET acked = 1, acked_at_ms = $1 WHERE run_id = $2 AND acked = 0"), [ackedAt, runId]);
|
|
318
318
|
}
|
|
319
319
|
async listPending() {
|
|
320
|
-
const { rows } = await this.db.query("SELECT run_id, scope, source_task_id, principal,
|
|
320
|
+
const { rows } = await this.db.query("SELECT run_id, scope, source_task_id, principal, created_at_ms FROM workflow_notify_journal WHERE acked = 0");
|
|
321
321
|
return rows.map((r) => ({
|
|
322
322
|
runId: String(r.run_id),
|
|
323
323
|
scope: String(r.scope),
|
|
324
324
|
acked: false,
|
|
325
325
|
...(r.source_task_id != null ? { sourceTaskId: String(r.source_task_id) } : {}),
|
|
326
326
|
...(r.principal != null ? { principal: String(r.principal) } : {}),
|
|
327
|
-
createdAt: Number(r.
|
|
327
|
+
createdAt: Number(r.created_at_ms),
|
|
328
328
|
}));
|
|
329
329
|
}
|
|
330
330
|
/** Retention (same sweep as reapAllScopes): ACKED rows are pure history — without this the twin re-opens
|
|
331
331
|
* the unbounded-growth hole the same release closed for workflow_run. Pending rows are NEVER reaped (they
|
|
332
332
|
* are the recovery backlog; the orphan-grace sweep is what retires a stuck pending run). */
|
|
333
333
|
async reapAcked(before) {
|
|
334
|
-
const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND
|
|
334
|
+
const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < ?", "DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < $1"), [before]);
|
|
335
335
|
return res.affected;
|
|
336
336
|
}
|
|
337
337
|
async get(runId) {
|
|
338
|
-
const { rows } = await this.db.query(this.q("SELECT run_id, scope, acked, source_task_id, principal,
|
|
338
|
+
const { rows } = await this.db.query(this.q("SELECT run_id, scope, acked, source_task_id, principal, created_at_ms, acked_at_ms FROM workflow_notify_journal WHERE run_id = ?", "SELECT run_id, scope, acked, source_task_id, principal, created_at_ms, acked_at_ms FROM workflow_notify_journal WHERE run_id = $1"), [runId]);
|
|
339
339
|
const r = rows[0];
|
|
340
340
|
if (!r)
|
|
341
341
|
return null;
|
|
@@ -345,8 +345,8 @@ export class SqlWorkflowNotifyJournalStore {
|
|
|
345
345
|
acked: Number(r.acked) === 1,
|
|
346
346
|
...(r.source_task_id != null ? { sourceTaskId: String(r.source_task_id) } : {}),
|
|
347
347
|
...(r.principal != null ? { principal: String(r.principal) } : {}),
|
|
348
|
-
createdAt: Number(r.
|
|
349
|
-
...(r.
|
|
348
|
+
createdAt: Number(r.created_at_ms),
|
|
349
|
+
...(r.acked_at_ms != null ? { ackedAt: Number(r.acked_at_ms) } : {}),
|
|
350
350
|
};
|
|
351
351
|
}
|
|
352
352
|
}
|
package/dist/run-local.js
CHANGED
|
@@ -53,7 +53,7 @@ import { applyEffective, resolveMcpServers, mcpForScenario } from "./config-cent
|
|
|
53
53
|
import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
|
|
54
54
|
import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
|
|
55
55
|
import { loadSkills } from "./capabilities/skills.js";
|
|
56
|
-
import {
|
|
56
|
+
import { createRepoClient } from "./capabilities/repo-tools.js";
|
|
57
57
|
import { webSearchConfigFromEnv, createWebSearchBackend } from "./plugins/web-search.js";
|
|
58
58
|
import { buildScenarios, selectScenario, centerScenarios } from "./capabilities/scenarios.js";
|
|
59
59
|
import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
|
|
@@ -539,7 +539,7 @@ export async function runLocal(argv, deps = {}) {
|
|
|
539
539
|
const handslessSubRunner = new Runner(withoutExecutionEnv(subRunnerDeps));
|
|
540
540
|
// Capability layer (loadSkills + buildScenarios + selectScenario) — assembled exactly like main.ts.
|
|
541
541
|
const skills = loadSkills(config.skillsDir);
|
|
542
|
-
const repoClient = config.gitApiBaseUrl ?
|
|
542
|
+
const repoClient = config.gitApiBaseUrl ? createRepoClient(config.gitApiKind, config.gitApiBaseUrl, config.gitApiToken) : undefined;
|
|
543
543
|
// systematic-audit: wire the env-configured WebSearch backend (WEB_SEARCH_PROVIDER) exactly like main.ts, so the
|
|
544
544
|
// default scenario's WebSearch tool is assembled on the CLI path too (it was silently never built before).
|
|
545
545
|
const webSearchCfg = webSearchConfigFromEnv();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.10.0",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|