@thincoder/core 0.9.1 → 0.9.3
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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/advisor/loop.mjs +2 -2
- package/advisor/run.mjs +1 -1
- package/agent/completion.mjs +3 -1
- package/agent/family-tools.mjs +24 -11
- package/agent/helpers.mjs +11 -2
- package/agent/run-stages.mjs +27 -5
- package/agent/setup-reminders.mjs +67 -11
- package/agent/setup.mjs +6 -0
- package/agent/write-gate.mjs +5 -5
- package/agent-tools/advisor-async.mjs +4 -4
- package/agent-tools/advisor.mjs +3 -3
- package/agent-tools/async-discard.mjs +1 -1
- package/agent-tools/audit-block.mjs +106 -0
- package/agent-tools/batch-lifecycle.mjs +301 -0
- package/agent-tools/batch-segment.mjs +16 -263
- package/agent-tools/batch-skeleton.mjs +156 -0
- package/agent-tools/batch.mjs +410 -0
- package/agent-tools/context.mjs +174 -0
- package/agent-tools/eng.mjs +4 -0
- package/agent-tools/goal.mjs +7 -0
- package/agent-tools/parent-channel.mjs +18 -1
- package/agent-tools/plan.mjs +39 -5
- package/agent-tools/read-history.mjs +122 -24
- package/agent-tools/settings.mjs +4 -2
- package/agent-tools/subagent-async.mjs +3 -3
- package/agent-tools/subagent-spawn.mjs +29 -101
- package/agent-tools/task.mjs +11 -0
- package/agent-tools.mjs +9 -2
- package/agent.mjs +10 -4
- package/config.mjs +1 -1
- package/context.mjs +66 -121
- package/fts-text.mjs +41 -0
- package/generate-title.mjs +6 -6
- package/i18n.mjs +4 -4
- package/ledger-cmd.mjs +30 -7
- package/ledger-db.mjs +22 -2
- package/ledger-executors.mjs +103 -0
- package/ledger-surface.mjs +15 -8
- package/ledger.mjs +15 -4
- package/manifest.mjs +172 -60
- package/memory/core.mjs +4 -18
- package/memory/schema.mjs +4 -11
- package/package.json +22 -1
- package/prompts/advisor-design.md +1 -1
- package/prompts/advisor-round2.md +1 -1
- package/prompts/advisor-round3.md +1 -1
- package/prompts/common.md +3 -3
- package/prompts/discipline-engineering.md +17 -2
- package/prompts/persona-eng-coder.md +1 -1
- package/prompts/persona-eng-designer.md +1 -1
- package/prompts/persona-engineering.md +9 -6
- package/session-gc.mjs +129 -74
- package/session-index-build.mjs +298 -0
- package/session-index-cmd.mjs +61 -0
- package/session-index-pass.mjs +95 -0
- package/session-index-query.mjs +102 -0
- package/session-index.mjs +285 -0
- package/session-lifecycle.mjs +18 -5
- package/session-slots-manifest.mjs +55 -3
- package/session-stale.mjs +247 -0
- package/token-window.mjs +188 -0
- package/tools/bash.mjs +4 -15
- package/tools/execute.mjs +5 -13
- package/tools/git-checkpoint.mjs +1 -1
- package/tools/git-ext.mjs +23 -20
- package/tools/git-run.mjs +141 -0
- package/tools/git.mjs +56 -45
- package/tools/index.mjs +3 -1
- package/tools/process-tree.mjs +20 -0
- package/tools/shared.mjs +8 -4
- package/traces/trace-cleanup.mjs +109 -0
- package/traces/trace-store.mjs +32 -36
package/agent.mjs
CHANGED
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
|
|
27
27
|
MIN_REPORT_CHARS, REPORT_CONTINUATION,
|
|
28
28
|
AUTO_TURN_DIGEST_DOMAIN,
|
|
29
|
+
AUTO_TURN_DIGEST_DOMAIN_ENG, // §6.15.3(F10 第三面):工程模式 digest 基座变体(task 指针改批次档 + 台账)
|
|
29
30
|
UPSTREAM_TURN_DOMAIN, // §6.27.12.8:上行唤醒轮域文本(手动档——ask 轮不沿用 digest 域文本)
|
|
30
|
-
restoreGuard, //
|
|
31
|
+
restoreGuard, // digest D-S6 读侧单点(AGENT-LOOP-ASYNC-POOL.md §6.8;P2 机制层端差批 §2.18——键清单归核)
|
|
31
32
|
} from "./agent/helpers.mjs"
|
|
32
33
|
// ENG 提醒族 + auto-turn domain 2026-09-05 迁 agent/helpers.mjs(agent.mjs 530 > 500 硬限)
|
|
33
34
|
// PROMPT-SYSTEM 施工② G1(2026-09-10):六件槽位常量装载收口 prompt-overlays.mjs
|
|
@@ -159,13 +160,15 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
159
160
|
agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
|
|
160
161
|
agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
|
|
161
162
|
agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
|
|
163
|
+
// F-CC2(§6.16.2):模型主动压缩的排队槽也是回合级——上一回合末尾未被安全点消费的请求不得跨回合生效
|
|
164
|
+
agent._pendingCompact = null
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
|
-
//
|
|
167
|
+
// digest D-S6 manual tier(AGENT-LOOP-ASYNC-POOL.md §6.8): action-domain reminder (system-driven turn — organize only).
|
|
165
168
|
// §6.27.12.4 ②: an up-stream wake turn answers a RUNNING subagent waiting for the reply — it
|
|
166
169
|
// must not reuse the digest text ("no one is waiting" is the opposite of the truth).
|
|
167
170
|
if ((autoTurn || upstreamTurn) && !agent.autoApprove) {
|
|
168
|
-
agent.history.push({ role: "user", content: upstreamTurn ? UPSTREAM_TURN_DOMAIN : AUTO_TURN_DIGEST_DOMAIN, transient: true })
|
|
171
|
+
agent.history.push({ role: "user", content: upstreamTurn ? UPSTREAM_TURN_DOMAIN : (agent.config?.agent?.engineering === true ? AUTO_TURN_DIGEST_DOMAIN_ENG : AUTO_TURN_DIGEST_DOMAIN), transient: true })
|
|
169
172
|
}
|
|
170
173
|
// eng-coder authorization (_engDesignReviewed) is eng-coder-only: set by subagent-spawn.mjs
|
|
171
174
|
// (spawn gate) / design-token.mjs (design review pass) BEFORE the child runAgent — the
|
|
@@ -192,8 +195,11 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
192
195
|
// context.mjs compressIfNeeded 经 extras 透出到 logCtx)
|
|
193
196
|
traceDepth: depth,
|
|
194
197
|
}
|
|
198
|
+
// F-CC1(§6.16.4 阈值单源接线):本回合压缩判定所用的阈值 + 固定开销面暂存——
|
|
199
|
+
// `context` 工具的 stats 报**同一口径**(不自行重算第二口径;VSC checkAndCompact 同款暂存)。
|
|
200
|
+
agent._ctxBasis = { threshold, overhead: compactionOverhead }
|
|
195
201
|
|
|
196
|
-
// SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-
|
|
202
|
+
// SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-UPSTREAM.md §6.27.4 消费点):子 → 父在飞消息的
|
|
197
203
|
// 回合边界注入单点取用一次(模块缓存 ⇒ 每 run 一次代价);动态 import = 零新增静态边
|
|
198
204
|
// (先例 = 上方 injectAsyncResult :113-117)。
|
|
199
205
|
const { drainChildUpstream } = await import("./agent-tools/parent-channel.mjs")
|
package/config.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export const DEFAULTS = {
|
|
|
49
49
|
advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
|
|
50
50
|
autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
|
|
51
51
|
engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
|
|
52
|
-
// Async pool limits (AGENT-LOOP-
|
|
52
|
+
// Async pool limits (AGENT-LOOP-ASYNC-POOL.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
|
|
53
53
|
// UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
|
|
54
54
|
// pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
|
|
55
55
|
// 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
|
package/context.mjs
CHANGED
|
@@ -7,56 +7,13 @@
|
|
|
7
7
|
* typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
|
|
8
8
|
* work after compaction. The earliest messages now go into the summary (which distinguishes completed
|
|
9
9
|
* vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
|
|
10
|
+
*
|
|
11
|
+
* 2026-09-21(context-tool 批 · CONTEXT-COMPACTION.md §6.16.8):计量 / 尾族 / 切分三族**逐字迁出** `token-window.mjs`(495 → ≈428——硬限 500 内);`estimateTokens` 经本档**再导出**保既有 import 面;新增面 = 模型主动压缩接线(`force` / `focus` + 焦点块 + anchor)与 prune 应用面。
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { chat } from "./provider/index.mjs"
|
|
13
|
-
import { estimateText } from "./provider/rate.mjs"
|
|
14
|
-
import { providerSpec } from "./config.mjs"
|
|
15
15
|
import { buildCompressMessages } from "./compress-form.mjs"
|
|
16
|
-
|
|
17
|
-
const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
|
|
18
|
-
|
|
19
|
-
/** Rough token count for a list of messages (body + reasoning + tool_calls params) */
|
|
20
|
-
export function estimateTokens(messages) {
|
|
21
|
-
let tokens = 0
|
|
22
|
-
for (const m of messages) {
|
|
23
|
-
if (typeof m.content === "string") tokens += estimateText(m.content)
|
|
24
|
-
else if (Array.isArray(m.content)) {
|
|
25
|
-
for (const part of m.content) {
|
|
26
|
-
if (part.type === "text") tokens += estimateText(part.text)
|
|
27
|
-
else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
31
|
-
for (const tc of m.tool_calls ?? []) {
|
|
32
|
-
tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return tokens
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
|
|
39
|
-
// sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
|
|
40
|
-
// summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
|
|
41
|
-
// Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
|
|
42
|
-
// at 40% of history; §6.4④ D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
|
|
43
|
-
// × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §6.9) tightens it over pair-safe boundaries when compaction runs,
|
|
44
|
-
// never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
|
|
45
|
-
const TAIL_BUDGET_FRACTION = 0.15
|
|
46
|
-
const SUMMARY_TOKEN_ESTIMATE = 1000 // §6.9: summary output target ~1K tokens — reserved from the 15%
|
|
47
|
-
const TAIL_FLOOR_MESSAGES = 10 // §6.4④ D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
|
|
48
|
-
function keepTailSize(provider, historyLen) {
|
|
49
|
-
// provider is guaranteed at every call site (runAgent always builds one); providerSpec
|
|
50
|
-
// degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
|
|
51
|
-
// because the 40% history cap still bounds the tail. providers[].context override
|
|
52
|
-
// (K units) is honored here (PROVIDER.md §6.15 T-C2: tail formula follows the window).
|
|
53
|
-
const ctxWindow = providerSpec(provider).context
|
|
54
|
-
return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
|
|
55
|
-
}
|
|
56
|
-
// §6.4④ D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §6.4④).
|
|
57
|
-
function tailBudgetTokens(provider) {
|
|
58
|
-
return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
|
|
59
|
-
}
|
|
16
|
+
import { contextUsage, estimateTokens, collectStaleToolOutputs, keepTailSize, splitHistory, tailBudgetTokens } from "./token-window.mjs"
|
|
60
17
|
|
|
61
18
|
export const SUMMARIZE_PROMPT = `The conversation above is our work log so far — summarize it into a compact summary for use as context in the ongoing conversation.
|
|
62
19
|
Requirements:
|
|
@@ -93,72 +50,25 @@ const FALLBACK_NOTE =
|
|
|
93
50
|
"Re-verify any state you need with tools before relying on it.]\n\n"
|
|
94
51
|
|
|
95
52
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
|
|
99
|
-
* `budgetTokens` (optional, §6.4④ D-T1): when the candidate's estimate exceeds it, the boundary moves
|
|
100
|
-
* forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
|
|
101
|
-
* the 40% cap made it < 10 — short history).
|
|
53
|
+
* focus 指令块(F-CC2 · §6.16.2):模型主动压缩时追加在摘要指令**尾段**——摘要按它加权取舍。`anchor` = task/goal 状态行;
|
|
54
|
+
* `anchor == null`(无 task 且无 goal)⇒ **anchor 段省略**,focus 正文恒保留(不产空标题)。
|
|
102
55
|
*/
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
|
|
109
|
-
while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
|
|
110
|
-
}
|
|
111
|
-
const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
|
|
112
|
-
if (candidate <= headEnd) return null
|
|
113
|
-
let tailStart = candidate
|
|
114
|
-
// §6.4④ D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
|
|
115
|
-
if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
|
|
116
|
-
const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
|
|
117
|
-
if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
|
|
118
|
-
}
|
|
119
|
-
return { headEnd, tailStart }
|
|
120
|
-
}
|
|
56
|
+
const focusBlock = (focus, anchor) =>
|
|
57
|
+
`\n\nThis compaction happens at my own request and is weighted toward the work coming next:\n${focus}\n\n` +
|
|
58
|
+
`Keep what that work needs at full fidelity — files, decisions, constraints, open threads; compress ` +
|
|
59
|
+
`everything else harder.` +
|
|
60
|
+
(anchor ? ` Current task/goal state (attached automatically):\n${anchor}` : "")
|
|
121
61
|
|
|
122
62
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
|
|
126
|
-
* assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
|
|
63
|
+
* anchor 取值(§6.16.2「自动附任务 / 目标(F-CC2 明文)」):取值源 = **工具面单源**——`task` / `goal` 工具写入的
|
|
64
|
+
* `agent.tasks` / `agent.goal`,不新增第二份状态;行格式沿用既有任务重注入形态(`- [status] title` + goal 一行)。两者皆空 ⇒ null(anchor 段整体省略)。
|
|
127
65
|
*/
|
|
128
|
-
function
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const m = history[i]
|
|
135
|
-
if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
|
|
136
|
-
tailStart = i
|
|
137
|
-
break
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
|
|
141
|
-
tailStart++
|
|
142
|
-
}
|
|
143
|
-
return tailStart
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* §6.4④ D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
|
|
148
|
-
* the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
|
|
149
|
-
* positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
|
|
150
|
-
* middle (D5); pairing is contiguous in the machine line (§6.4③) — every non-tool boundary is safe.
|
|
151
|
-
* No fit before the floor → keep the floor, accept the overrun.
|
|
152
|
-
*/
|
|
153
|
-
function tightenTailByBudget(history, start, floorStart, budgetTokens) {
|
|
154
|
-
const suffixTokens = new Array(history.length + 1)
|
|
155
|
-
suffixTokens[history.length] = 0
|
|
156
|
-
for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
|
|
157
|
-
if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
|
|
158
|
-
for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
|
|
159
|
-
if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
|
|
160
|
-
}
|
|
161
|
-
return floorStart
|
|
66
|
+
function anchorText(agent) {
|
|
67
|
+
const lines = []
|
|
68
|
+
const g = agent?.goal
|
|
69
|
+
if (g?.objective) lines.push(`- goal [${g.status}] ${g.objective}${g.criteria ? ` — done when: ${g.criteria}` : ""}`)
|
|
70
|
+
for (const t of agent?.tasks ?? []) lines.push(`- [${t.status}] ${t.title}`)
|
|
71
|
+
return lines.length > 0 ? lines.join("\n") : null
|
|
162
72
|
}
|
|
163
73
|
|
|
164
74
|
/**
|
|
@@ -359,21 +269,16 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
359
269
|
* generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
|
|
360
270
|
* internal mechanism, not a model reply); onCompressStart fires right before the summary call
|
|
361
271
|
* (§6.8 D-C1, compression lifecycle visibility — panel start state)
|
|
362
|
-
* @param {object} extras - { systemPrompt?, tools? } —
|
|
363
|
-
*
|
|
272
|
+
* @param {object} extras - { systemPrompt?, tools?, force?, focus? } — 固定开销面(system + tools 估计)+
|
|
273
|
+
* 模型主动压缩面(§6.16.2):`force` **只跳过** `tokens <= threshold` 早退,其余全同;`focus` 追加在摘要指令尾段。
|
|
364
274
|
*/
|
|
365
275
|
export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
|
|
366
276
|
const history = agent.history
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const tokens =
|
|
373
|
-
agent._lastPromptTokens != null
|
|
374
|
-
? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
|
|
375
|
-
: estimateTokens(history) + overhead
|
|
376
|
-
if (tokens <= threshold) return false
|
|
277
|
+
// 单源(§6.16.4):total / overhead 与 stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
|
|
278
|
+
const usage = contextUsage(agent, extras)
|
|
279
|
+
const tokens = usage.total
|
|
280
|
+
const overhead = usage.overhead
|
|
281
|
+
if (!extras.force && tokens <= threshold) return false
|
|
377
282
|
|
|
378
283
|
const keepTail = keepTailSize(agent.provider, history.length)
|
|
379
284
|
const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
|
|
@@ -398,8 +303,15 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
398
303
|
// the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
|
|
399
304
|
callbacks?.onCompressStart?.({ messages: middle.length })
|
|
400
305
|
const startedAt = performance.now()
|
|
306
|
+
// F-CC2 焦点块(§6.16.2):仅模型主动面携带——追加在**指令末条尾段**(`SUMMARIZE_PROMPT` 文本零改,
|
|
307
|
+
// 只追加焦点块;无 focus ⇒ 请求体逐字节同修前——compress-form.test.mjs 零回归)。
|
|
308
|
+
const messages = buildCompressMessages(history, split.tailStart, extras?.systemPrompt, SUMMARIZE_PROMPT)
|
|
309
|
+
if (extras.focus) {
|
|
310
|
+
const last = messages.at(-1)
|
|
311
|
+
messages[messages.length - 1] = { ...last, content: last.content + focusBlock(String(extras.focus), anchorText(agent)) }
|
|
312
|
+
}
|
|
401
313
|
const summary = await chat({ ...agent.provider, thinking: null }, {
|
|
402
|
-
messages
|
|
314
|
+
messages,
|
|
403
315
|
tools: extras?.tools,
|
|
404
316
|
signal,
|
|
405
317
|
// §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
|
|
@@ -430,6 +342,37 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
430
342
|
return true
|
|
431
343
|
}
|
|
432
344
|
|
|
345
|
+
/** prune stub 逐字(§6.16.3):只给「已清理 + 原长度 + 重跑路径」——prune 是**删**不是摘要,不声称可复原。 */
|
|
346
|
+
const pruneStub = (chars) => `[pruned: stale tool output dropped (${chars} chars) — re-run the tool if you need it again.]`
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 陈旧工具输出清理(F-CC3 · §6.16.3):合格集 = `token-window.mjs` 单源(保护尾外 ∧ `role:"tool"` ∧ ≥ 门槛);
|
|
350
|
+
* 命中项**原位换「内容」**(`history[i] = { ...m, content: stub }`——数组引用 / 长度 / 索引 / `tool_call_id` 全不变
|
|
351
|
+
* ⇒ 配对**结构上不可能被拆**)。记录面零改(copy-on-write:消息对象与人读线共享);基线失效同 `shrinkOversized` 先例。
|
|
352
|
+
* @returns {{pruned:number, freed:number, candidates:number, tailKept:number, belowMin:number}}
|
|
353
|
+
*/
|
|
354
|
+
export function pruneStaleToolOutputs(agent) {
|
|
355
|
+
const history = agent.history
|
|
356
|
+
const stale = collectStaleToolOutputs(history, agent.provider)
|
|
357
|
+
const counts = {
|
|
358
|
+
pruned: stale.indexes.length,
|
|
359
|
+
freed: stale.tokens,
|
|
360
|
+
candidates: stale.candidates,
|
|
361
|
+
tailKept: stale.tailKept,
|
|
362
|
+
belowMin: stale.belowMin,
|
|
363
|
+
}
|
|
364
|
+
if (counts.pruned === 0) return counts
|
|
365
|
+
for (const i of stale.indexes) {
|
|
366
|
+
const m = history[i]
|
|
367
|
+
// 多模态 tool 结果(content 数组)整体替换为 stub ⇒ 图像 part 丢弃(不可再取——prune 是删;回执只给重跑路径)
|
|
368
|
+
const chars = typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length
|
|
369
|
+
history[i] = { ...m, content: pruneStub(chars) }
|
|
370
|
+
}
|
|
371
|
+
agent._lastPromptTokens = null
|
|
372
|
+
agent._usageAtLen = null
|
|
373
|
+
return counts
|
|
374
|
+
}
|
|
375
|
+
|
|
433
376
|
/**
|
|
434
377
|
* Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
|
|
435
378
|
* Drops the middle so the task can continue. Returns whether truncation happened.
|
|
@@ -488,6 +431,8 @@ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
|
|
|
488
431
|
return shrunk
|
|
489
432
|
}
|
|
490
433
|
|
|
434
|
+
// ─── 迁出面(import 面保持:TUI / verify-compress / VSC 对拍经本档取——先例 = 下方 explore-distill 再导出)──
|
|
435
|
+
export { estimateTokens }
|
|
491
436
|
// ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
|
|
492
437
|
// 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
|
|
493
438
|
// explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
|
package/fts-text.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fts-text.mjs — FTS5 语言面单一来源(会话索引批 · SESSION.md §6.19 D-SE44)。
|
|
3
|
+
*
|
|
4
|
+
* `segmentCJK` / `buildFtsQuery` 自 `memory/schema.mjs` / `memory/core.mjs` **外提**为本叶子档
|
|
5
|
+
* (两档 re-export 保名面——零行为变更):理由 = 会话索引不得把 memory 模块链拉进
|
|
6
|
+
* `read_history` 的装配装载面(W8 契约②)+ 机制单源(D2——写入侧与查询侧同一处理)。
|
|
7
|
+
* 消费者 = memory 面(写入 / 检索)+ 会话索引面(`session-index.mjs` / `session-index-build.mjs`)。
|
|
8
|
+
*
|
|
9
|
+
* 零项目内依赖(仅 `node:`)——叶子档,可被任意装配面静态 import。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 查询词元上限(超限截断——`buildFtsQuery` 单源)。 */
|
|
13
|
+
export const FTS_TOKEN_MAX = 16
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* CJK 逐字间隔:让 unicode61 把汉字 / 假名 / 谚文逐字当作独立 token。
|
|
17
|
+
* 写入与查询两侧必须用同一处理才可召回(两字词如「分号」→「分 号」短语仍命中;ASCII 保持整词)。
|
|
18
|
+
*/
|
|
19
|
+
export function segmentCJK(text) {
|
|
20
|
+
return text.replace(
|
|
21
|
+
/[-ヿ㐀-䶿一-鿿豈-가-]+/g,
|
|
22
|
+
(run) => [...run].join(" "),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 构造 FTS5 查询:先按空白 / 标点切 token,再对每个 token 做 CJK 逐字分段。
|
|
28
|
+
* 多字 CJK 词保持为 FTS5 短语(「分号」→「分 号」→ 短语查询,邻接精确匹配),
|
|
29
|
+
* 不同 token 以 OR 连接(「命名 规范」→「命 名」OR「规 范」,各自短语要求自身邻接)。
|
|
30
|
+
* 无 token ⇒ 返回空串(消费侧据此退化——SESSION.md §6.19 边界情形表)。
|
|
31
|
+
*/
|
|
32
|
+
export function buildFtsQuery(query) {
|
|
33
|
+
const terms = query
|
|
34
|
+
.split(/[\s,,。、;;!!??()()"`]+/)
|
|
35
|
+
.map((t) => t.trim())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.slice(0, FTS_TOKEN_MAX)
|
|
38
|
+
.map((t) => segmentCJK(t))
|
|
39
|
+
if (terms.length === 0) return ""
|
|
40
|
+
return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
|
|
41
|
+
}
|
package/generate-title.mjs
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { proxyFetch } from "./proxy.mjs"
|
|
12
|
+
// D7(2026-09-21 块标题行对齐批——标题链单源):首条真实 user 消息谓词收核单源
|
|
13
|
+
// (`isRealUserMsg`——纯函数零依赖,无环)。
|
|
14
|
+
import { isRealUserMsg } from "./history-window.mjs"
|
|
12
15
|
|
|
13
16
|
// Test seam (_-prefix, mirrors run.mjs seams): lets the proxy-branch regression
|
|
14
17
|
// test swap the proxy fetch. The branch it exercises used to carry a dynamic
|
|
@@ -105,13 +108,10 @@ export async function ensureSessionTitle(agent) {
|
|
|
105
108
|
if (agent.title) return agent.title
|
|
106
109
|
try {
|
|
107
110
|
// TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14):绑定态首条 user 消息在记录存储(段 1)——
|
|
108
|
-
// 内存窗口可能已滑过它(store.firstUserMessage
|
|
111
|
+
// 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找
|
|
112
|
+
// (谓词单源 = `isRealUserMsg`——与 VSC 端壳同判据)。
|
|
109
113
|
let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
|
|
110
|
-
if (!firstUser)
|
|
111
|
-
firstUser = (agent._fullHistory ?? agent.history).find(
|
|
112
|
-
(m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
|
|
113
|
-
)
|
|
114
|
-
}
|
|
114
|
+
if (!firstUser) firstUser = (agent._fullHistory ?? agent.history).find(isRealUserMsg)
|
|
115
115
|
if (firstUser) {
|
|
116
116
|
const title = await generateTitle(firstUser.content, agent.provider)
|
|
117
117
|
if (title) agent.title = title
|
package/i18n.mjs
CHANGED
|
@@ -35,10 +35,10 @@ export const CORE_MESSAGES = Object.freeze({
|
|
|
35
35
|
"digest.done": { en: "Digested ${n} background report(s) (${seconds}s)", zh: "已消化 ${n} 份后台报告(${seconds}s)" },
|
|
36
36
|
"digest.aborted": { en: "Digestion interrupted (${seconds}s)", zh: "消化中断(${seconds}s)" },
|
|
37
37
|
"digest.turnLabel": { en: "[auto-turn: digesting finished subagent reports…]", zh: "自动回合:消化已完成的子代理报告…" },
|
|
38
|
-
// M4(2026-09-20
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
"digest.
|
|
38
|
+
// M4(2026-09-20 端差·显示面消差批)+ F-UC8(2026-09-21 信号提示行批 · §6.27.12.13 ①–②):起跑标签
|
|
39
|
+
// **两档**——ask 档携参「谁 + 啥」(`${from}` / `${msg}`——核单点 `upstreamAskLabelVars`)/ digest 档(下键);
|
|
40
|
+
// manual / AUTO **同判**(`auto` 泛句无生产者 ⇒ 键退场);VSC 本地档不重复定义(单一权威容器)。
|
|
41
|
+
"digest.turnLabelAsk": { en: "[auto-turn: answering ${from}: ${msg}]", zh: "自动回合:答复 ${from}:${msg}" },
|
|
42
42
|
"digest.capAuto": { en: "[auto-turn: continuing past turn cap…]", zh: "自动回合:越过轮次上限,继续推进…" },
|
|
43
43
|
"digest.capStop": { en: "[auto-turn stopped at ${turns} turns — partial digest; finished reports stay in history]", zh: "自动回合在 ${turns} 轮处停止——部分消化;已完成的报告保留在历史中" },
|
|
44
44
|
// ── 限流 / 供应商状态行 ──
|
package/ledger-cmd.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function ledgerCount({ cwd } = {}) {
|
|
|
37
37
|
/** 路径 = 存在的**档**?(目录 / 缺失 → false——写门存在性判据用)。 */
|
|
38
38
|
const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
|
|
39
39
|
|
|
40
|
-
/** 写门·指针存在性(设计档 §6.1 · 台账 #38 · AC-M2-
|
|
40
|
+
/** 写门·指针存在性(设计档 §6.1 · 台账 #38 · AC-M2-9):写命令落盘前判**结果行**——`status ∈
|
|
41
41
|
* {在途, 待核销}` 且 `task_book` 非空 ⇒ 文件部分(首个 `§` 前子串,trim)须经基准 `resolve(base, …)`
|
|
42
42
|
* 指向存在的档;缺文件部分(`§2` / 全空白)⇒ 拒;不在册 / 非文件 ⇒ 拒(throw,行不变)。
|
|
43
43
|
* 判位与迁移表判同层(落盘前);文案前缀 = 调用函数名(与同函数既有两条文案同款)。
|
|
@@ -67,8 +67,22 @@ export function ledgerAdd({ cwd, row }) {
|
|
|
67
67
|
} finally { db.close() }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/** executor 目标值计算(设计档 docs/core/design/LEDGER.md §3.1——判位在 ledgerUpdate 体内:迁移表判 → 本语义 → 写门 → UPDATE)。
|
|
71
|
+
* 优先级 = patch 显式 > 自动语义(进在途 = executorSessionId / 出在途 = NULL)> 行现值兜底 > NULL。 */
|
|
72
|
+
function resolveExecutorTarget(row, to, patch, executorSessionId) {
|
|
73
|
+
if (row.status === "待设计" && to === "在途") {
|
|
74
|
+
return patch.executor ?? executorSessionId ?? row.executor ?? null
|
|
75
|
+
}
|
|
76
|
+
if (row.status === "在途" && (to === "待核销" || to === "已废弃")) {
|
|
77
|
+
return null // 出边自动语义压 patch——显式传 executor 也不复活
|
|
78
|
+
}
|
|
79
|
+
return patch.executor ?? row.executor ?? null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 更新(写命令,仅主 agent):UPDATE——状态迁移前判允许迁移表(不在表内 → 拒,行不变)。
|
|
83
|
+
* executor 目标值随同一 UPDATE 落列(LEDGER.md §3.1);executorSessionId = 调用会话(函数参数注入——K-LX1
|
|
84
|
+
* 纯函数可测;工具层动态 import getSessionId() 供值,零新静态边)。 */
|
|
85
|
+
export function ledgerUpdate({ cwd, id, patch, executorSessionId }) {
|
|
72
86
|
const db = openLedger(cwd, { create: true })
|
|
73
87
|
try {
|
|
74
88
|
const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
|
|
@@ -79,9 +93,10 @@ export function ledgerUpdate({ cwd, id, patch }) {
|
|
|
79
93
|
}
|
|
80
94
|
const nextTaskBook = patch.task_book ?? row.task_book
|
|
81
95
|
assertTaskBookGate(cwd, "ledgerUpdate", to, nextTaskBook) // 判序:迁移表判 → 本门 → UPDATE
|
|
96
|
+
const nextExecutor = resolveExecutorTarget(row, to, patch, executorSessionId)
|
|
82
97
|
const now = nowIso()
|
|
83
|
-
db.prepare(`UPDATE items SET status = ?, title = ?, board = ?, req_doc = ?, task_book = ?, evidence = ?, trigger = ?, updated_at = ? WHERE id = ?`)
|
|
84
|
-
.run(to, patch.title ?? row.title, patch.board ?? row.board, patch.req_doc ?? row.req_doc, nextTaskBook, patch.evidence ?? row.evidence, patch.trigger ?? row.trigger, now, id)
|
|
98
|
+
db.prepare(`UPDATE items SET status = ?, title = ?, board = ?, req_doc = ?, task_book = ?, evidence = ?, trigger = ?, executor = ?, updated_at = ? WHERE id = ?`)
|
|
99
|
+
.run(to, patch.title ?? row.title, patch.board ?? row.board, patch.req_doc ?? row.req_doc, nextTaskBook, patch.evidence ?? row.evidence, patch.trigger ?? row.trigger, nextExecutor, now, id)
|
|
85
100
|
return { id }
|
|
86
101
|
} finally { db.close() }
|
|
87
102
|
}
|
|
@@ -97,7 +112,8 @@ export function ledgerClose({ cwd, id, status }) {
|
|
|
97
112
|
if (!row) throw new Error(`ledgerClose:行 ${id} 不存在`)
|
|
98
113
|
if (status === "已核销" && row.status !== "待核销") throw new Error(`ledgerClose:勾销仅限待核销(现态 ${row.status})`)
|
|
99
114
|
const now = nowIso()
|
|
100
|
-
|
|
115
|
+
// 撤回(任意态 → 已废弃——含在途直撤)同步 executor = NULL(LEDGER.md §3.1 ④);勾销路径 executor 已在离场迁移清空,零触碰
|
|
116
|
+
db.prepare("UPDATE items SET status = ?, closed_at = ?, updated_at = ?, executor = ? WHERE id = ?").run(status, now, now, status === "已废弃" ? null : row.executor, id)
|
|
101
117
|
db.exec("COMMIT")
|
|
102
118
|
return { id, status }
|
|
103
119
|
} catch (e) { db.exec("ROLLBACK"); throw e }
|
|
@@ -181,12 +197,19 @@ export const ledgerUpdateTool = {
|
|
|
181
197
|
task_book: { type: "string", description: "任务书指针(缺省 = 不变)" },
|
|
182
198
|
evidence: { type: "string", description: "证据(缺省 = 不变)" },
|
|
183
199
|
trigger: { type: "string", enum: ["归批", "条件", "认账不排期"], description: "触发(缺省 = 不变)" },
|
|
200
|
+
executor: { type: "string", description: "执行者 sessionId(可选——接手改写归属用;缺省 = 按状态迁移语义:进在途自动写本会话 / 出在途自动清空 / 其余不变)" },
|
|
184
201
|
},
|
|
185
202
|
},
|
|
186
203
|
readonly: false,
|
|
187
204
|
async execute(args, ctx) {
|
|
188
205
|
const { cwd, id, ...patch } = args
|
|
189
|
-
|
|
206
|
+
let executorSessionId
|
|
207
|
+
if (patch.executor === undefined) {
|
|
208
|
+
// 动态 import——零新静态边(K-LX1);工具层供值,核心函数保持参数注入纯函数
|
|
209
|
+
const { getSessionId } = await import("./session-slots.mjs")
|
|
210
|
+
executorSessionId = getSessionId()
|
|
211
|
+
}
|
|
212
|
+
return JSON.stringify(ledgerUpdate({ cwd: cwd ?? cwdOf(ctx), id, patch, executorSessionId }))
|
|
190
213
|
},
|
|
191
214
|
}
|
|
192
215
|
|
package/ledger-db.mjs
CHANGED
|
@@ -57,13 +57,30 @@ CREATE TABLE IF NOT EXISTS items (
|
|
|
57
57
|
task_book TEXT,
|
|
58
58
|
evidence TEXT,
|
|
59
59
|
trigger TEXT CHECK(trigger IN ('归批','条件','认账不排期') OR trigger IS NULL),
|
|
60
|
+
executor TEXT,
|
|
60
61
|
created_at TEXT,
|
|
61
62
|
updated_at TEXT,
|
|
62
63
|
closed_at TEXT,
|
|
63
64
|
CHECK (status NOT IN ('在途','待核销') OR (task_book IS NOT NULL AND task_book <> ''))
|
|
64
65
|
)`
|
|
65
66
|
|
|
66
|
-
/**
|
|
67
|
+
/** 老库幂等迁移:items 表缺 executor 列(旧 DDL 建的库)→ ALTER 补列。
|
|
68
|
+
* 判据 = PRAGMA table_info 实查(不猜版本号);ALTER duplicate column 竞争 → 复核列在吞 / 不在抛真错。
|
|
69
|
+
* 列存在性谓词抽成单元(param exists),便于并发竞争拍直测。K-LX5:老库零删改、零重建。 */
|
|
70
|
+
export function ensureExecutorColumn(db, { exists } = {}) {
|
|
71
|
+
const columnExists = exists
|
|
72
|
+
?? (() => db.prepare("SELECT 1 FROM pragma_table_info('items') WHERE name = 'executor'").get() !== undefined)
|
|
73
|
+
if (columnExists()) return false
|
|
74
|
+
try {
|
|
75
|
+
db.exec("ALTER TABLE items ADD COLUMN executor TEXT")
|
|
76
|
+
return true
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (columnExists()) return false // 并发竞争——另一连接已加列:吞
|
|
79
|
+
throw new Error(`台账库 executor 列迁移失败:${e.message}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 开库(cwd = 项目根——仅作关联键)→ DatabaseSync 句柄 + 幂等建表 + 老库幂等迁移(executor 列)。
|
|
67
84
|
* 读面(create=false):库文件不存在 → 返回 null(空账——读面不建库、无副作用)。
|
|
68
85
|
* 写面(create=true):库文件不存在自动建(台账目录随之创建);项目根目录不存在 → 抛友好错误。 */
|
|
69
86
|
export function openLedger(cwd, { create = false } = {}) {
|
|
@@ -75,7 +92,10 @@ export function openLedger(cwd, { create = false } = {}) {
|
|
|
75
92
|
if (!existsSync(file) && !create) return null
|
|
76
93
|
try { mkdirSync(ledgerDir, { recursive: true }) } catch { /* 已存在 / 并发建目录竞争——忽略 */ }
|
|
77
94
|
const db = new DatabaseSync(file)
|
|
78
|
-
try {
|
|
95
|
+
try {
|
|
96
|
+
db.exec(DDL)
|
|
97
|
+
ensureExecutorColumn(db)
|
|
98
|
+
} catch (e) {
|
|
79
99
|
db.close()
|
|
80
100
|
throw new Error(`台账库打开失败:${file}(${e.message})`)
|
|
81
101
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ledger-executors.mjs — 在途 executor 判活展示(F-LX1 · 设计档 LEDGER.md §7.3.1)。
|
|
3
|
+
*
|
|
4
|
+
* 红线:写径零探测——判活只落显示面。本档从 ledger.mjs 拆出(2026-09-21 executor 判活批,
|
|
5
|
+
* ledger.mjs 307 → ≤300 软线回落),语义零变:`resolveExecutorStates` async 单源 + `executorTail`
|
|
6
|
+
* 三态文案(双端同文——CLI L2 与 VSC tooltip 共此函数)+ TTL 缓存(与 peer-instances 同源口径)。
|
|
7
|
+
*/
|
|
8
|
+
import { classifyEnd, ownerState, probeOwnersAsync } from "./process-probe.mjs"
|
|
9
|
+
import { PEER_PROBE_TTL_MS } from "./peer-instances.mjs"
|
|
10
|
+
|
|
11
|
+
/** 判活解析 TTL 缓存(模块级——与 peer-instances `PEER_PROBE_TTL_MS` 同源口径;TTL 内重复解析零 exec)。
|
|
12
|
+
* 键 = pid;值 = { probedAt, state, end }。`_setExecutorProbeTtlForTest` = 测试缝(裁定 #8——测试间不串;用后恢复)。 */
|
|
13
|
+
const executorProbeCache = new Map()
|
|
14
|
+
let _executorProbeTtlOverride = null
|
|
15
|
+
|
|
16
|
+
/** 测试缝:覆盖 TTL 毫秒数(0 = 禁缓存恒重探);返回前值,测试 finally 恢复。 */
|
|
17
|
+
export function _setExecutorProbeTtlForTest(ms) {
|
|
18
|
+
const prev = _executorProbeTtlOverride
|
|
19
|
+
_executorProbeTtlOverride = ms
|
|
20
|
+
executorProbeCache.clear()
|
|
21
|
+
return prev
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const executorTtlMs = () => _executorProbeTtlOverride ?? PEER_PROBE_TTL_MS
|
|
25
|
+
|
|
26
|
+
/** sessionId → pid(首段整数——peer-instances `groupSlotSessions` 同款解析;不可解析 ⇒ null→unknown)。 */
|
|
27
|
+
function executorPid(sessionId) {
|
|
28
|
+
const n = Number.parseInt(String(sessionId).split("-")[0], 10)
|
|
29
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 行时间戳 → 距今天数(向下取整;缺 → null——「N 天未动」标注源,零新状态)。 */
|
|
33
|
+
function staleDaysOf(ts, now) {
|
|
34
|
+
if (ts == null) return null
|
|
35
|
+
const ms = Date.parse(ts)
|
|
36
|
+
if (!Number.isFinite(ms)) return null
|
|
37
|
+
return Math.floor(Math.max(0, now - ms) / 86400000)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 在途 executor 判活批量解析(async 单源——LEDGER.md §7.3.1):收集各 scan 的 `inflightExecutors`
|
|
41
|
+
* → pid 去重 → **一次 `probeOwnersAsync` 束**(≤1 判活 + ≤1 cmdline——禁止逐行 exec)→ 逐 executor
|
|
42
|
+
* `ownerState` → 每 scan 挂 `executors: [{executor, pid, end, state, staleDays}]` + `deadExecutors: n`。
|
|
43
|
+
* `end` = cmdline 可得时的 `classifyEnd` 值;`staleDays` = 该行 `updated_at ?? created_at` 距今天数。
|
|
44
|
+
* TTL 缓存按 pid(5s 同源口径);**无在途 executor ⇒ 零 exec 快返**(不探、不缓存)。
|
|
45
|
+
* 写径零探测——本函数仅供显示面调用(§7.3.1 红线)。 */
|
|
46
|
+
export async function resolveExecutorStates(scans, { now = Date.now() } = {}) {
|
|
47
|
+
const list = Array.isArray(scans) ? scans : []
|
|
48
|
+
const entries = []
|
|
49
|
+
for (const s of list) {
|
|
50
|
+
for (const e of Array.isArray(s?.inflightExecutors) ? s.inflightExecutors : []) entries.push({ scan: s, ...e })
|
|
51
|
+
}
|
|
52
|
+
// 每 scan 先挂空形(无在途 ⇒ 空数组 + 0——不触缓存不探测)
|
|
53
|
+
for (const s of list) { s.executors = []; s.deadExecutors = 0 }
|
|
54
|
+
if (entries.length === 0) return list
|
|
55
|
+
|
|
56
|
+
const pids = new Map() // pid → sessionId(去重——同 pid 多条只探一次)
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const pid = executorPid(e.executor)
|
|
59
|
+
if (pid !== null && !pids.has(pid)) pids.set(pid, e.executor)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// TTL 命中/未命中分流:未命中的 pid 集成一次批量探测
|
|
63
|
+
const ttl = executorTtlMs()
|
|
64
|
+
const need = [...pids.keys()].filter((pid) => {
|
|
65
|
+
const hit = executorProbeCache.get(pid)
|
|
66
|
+
return !hit || now - hit.probedAt >= ttl
|
|
67
|
+
})
|
|
68
|
+
const bundle = need.length > 0 ? await probeOwnersAsync(need) : { aliveSet: null, cmds: null }
|
|
69
|
+
for (const pid of need) {
|
|
70
|
+
const cmdline = typeof bundle?.cmds?.get?.(pid) === "string" ? bundle.cmds.get(pid) : undefined
|
|
71
|
+
executorProbeCache.set(pid, {
|
|
72
|
+
probedAt: now,
|
|
73
|
+
state: ownerState(pid, bundle),
|
|
74
|
+
end: cmdline ? classifyEnd(cmdline) : undefined,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
const deadByScan = new Map()
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const pid = executorPid(e.executor)
|
|
80
|
+
const hit = pid !== null ? executorProbeCache.get(pid) : null
|
|
81
|
+
const state = hit ? hit.state : "unknown" // pid 不可解析 ⇒ unknown(不判死——保守)
|
|
82
|
+
const end = hit ? hit.end : undefined
|
|
83
|
+
const staleDays = staleDaysOf(e.updated_at ?? e.created_at, now)
|
|
84
|
+
e.scan.executors.push({ executor: e.executor, pid, end, state, staleDays })
|
|
85
|
+
if (state === "dead") deadByScan.set(e.scan, (deadByScan.get(e.scan) ?? 0) + 1)
|
|
86
|
+
}
|
|
87
|
+
for (const [scan, n] of deadByScan) scan.deadExecutors = n
|
|
88
|
+
return list
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 判活尾段(三态文案单源——LEDGER.md §7.3.1;双端同文——CLI L2 与 VSC tooltip 共此函数):
|
|
92
|
+
* dead>0 → `(属主已死 <n>,可接手)`(dead 优先);死 0 · 在途 executor >0 · 最长 staleDays ≥1 →
|
|
93
|
+
* `(执行中 <n> · 最长 <d> 天未动)`;死 0 · staleDays <1 或 null → `(执行中 <n>)`;
|
|
94
|
+
* 在途 executor =0 → 空串(既有文案零破)。 */
|
|
95
|
+
export function executorTail(scan) {
|
|
96
|
+
const exs = Array.isArray(scan?.executors) ? scan.executors : []
|
|
97
|
+
if (exs.length === 0) return ""
|
|
98
|
+
const dead = exs.filter((x) => x.state === "dead").length
|
|
99
|
+
if (dead > 0) return `(属主已死 ${dead},可接手)`
|
|
100
|
+
const maxStale = Math.max(...exs.map((x) => (x.staleDays == null ? 0 : x.staleDays)))
|
|
101
|
+
if (maxStale >= 1) return `(执行中 ${exs.length} · 最长 ${maxStale} 天未动)`
|
|
102
|
+
return `(执行中 ${exs.length})`
|
|
103
|
+
}
|