@thincoder/core 0.9.1 → 0.9.2
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/LICENSE +21 -0
- package/README.md +89 -0
- package/advisor/loop.mjs +2 -2
- package/advisor/run.mjs +1 -1
- package/agent/family-tools.mjs +20 -10
- package/agent/setup-reminders.mjs +67 -11
- package/agent-tools/advisor.mjs +2 -2
- package/agent-tools/batch-lifecycle.mjs +245 -0
- package/agent-tools/batch-segment.mjs +16 -263
- package/agent-tools/batch-skeleton.mjs +94 -0
- package/agent-tools/batch.mjs +397 -0
- package/agent-tools/eng.mjs +4 -0
- package/agent-tools/parent-channel.mjs +17 -0
- package/agent-tools/plan.mjs +34 -0
- package/agent-tools.mjs +5 -1
- 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/package.json +18 -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 +1 -1
- package/prompts/persona-eng-coder.md +1 -1
- package/prompts/persona-eng-designer.md +1 -1
- package/prompts/persona-engineering.md +8 -5
- package/session-gc.mjs +118 -74
- package/session-lifecycle.mjs +18 -5
- package/session-slots-manifest.mjs +36 -3
- package/session-stale.mjs +247 -0
- package/tools/git.mjs +14 -9
- package/traces/trace-cleanup.mjs +109 -0
- package/traces/trace-store.mjs +32 -36
|
@@ -27,7 +27,7 @@ You have a budget of 15 tool rounds (chat turns). Hard mechanical cap: 100 round
|
|
|
27
27
|
- Stop calling tools once you are ready to produce the review table.
|
|
28
28
|
|
|
29
29
|
## 批次档 §3 落档(仅设计评审——工具已挂载时)
|
|
30
|
-
设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch_segment
|
|
30
|
+
设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch`(过渡别名 `batch_segment`)时**——代码评审无此工具,本节不适用):在报告之外,用 `batch({action:"append", segment:"§3", text})` 把本轮**发现表 + VERDICT + 计数逐字**写进批次档 §3(不给路径参数;工具自带 `### 轮次 N(评审子代理)` 来源戳,勿自写标题)。
|
|
31
31
|
写不进去(被拒/失败)→ 报告里明说「§3 未写入」——不得静默略过,也不得假装写过(父侧代写必须打标)。
|
|
32
32
|
|
|
33
33
|
## Judgment Rules (apply directly — do not re-derive) Apply each rule to the extent it matches the review type: design review — doc-state rules (R1, R7a-e) apply; code review — all rules apply. R1 Doc contradiction / state inconsistency → 🟡 (report-and-fix by the parent doc layer — NOT 🔴; exception: the same mechanism described differently in two places = Document ownership 🔴 — keep this convention — do not downgrade)
|
|
@@ -23,7 +23,7 @@ You have a budget of 15 tool rounds (chat turns). Hard mechanical cap: 100 round
|
|
|
23
23
|
- Stop calling tools once you are ready to produce the review table.
|
|
24
24
|
|
|
25
25
|
## 批次档 §3 落档(仅设计评审——工具已挂载时)
|
|
26
|
-
设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch_segment
|
|
26
|
+
设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch`(过渡别名 `batch_segment`)时**——代码评审无此工具,本节不适用):在报告之外,用 `batch({action:"append", segment:"§3", text})` 把本轮**发现表 + VERDICT + 计数逐字**写进批次档 §3(不给路径参数;工具自带 `### 轮次 N(评审子代理)` 来源戳,勿自写标题)。
|
|
27
27
|
写不进去(被拒/失败)→ 报告里明说「§3 未写入」——不得静默略过,也不得假装写过(父侧代写必须打标)。
|
|
28
28
|
|
|
29
29
|
## Judgment Rules (apply directly — do not re-derive) Apply each rule to the extent it matches the review type: design review — doc-state rules (R1, R7a-e) apply; code review — all rules apply. R1 Doc contradiction / state inconsistency → 🟡 (report-and-fix by the parent doc layer — NOT 🔴; exception: the same mechanism described differently in two places = Document ownership 🔴 — keep this convention — do not downgrade)
|
package/prompts/common.md
CHANGED
|
@@ -153,6 +153,6 @@ Batch independent read-only tool calls into a single reply (they run concurrentl
|
|
|
153
153
|
## 批次档常识(Batch record — the carrier of engineering-mode task flow)
|
|
154
154
|
**What it is**: the **batch record** = the **carrier** of an engineering-mode task (the single file threading one implementation round from start to closeout); the path takes the form `docs/batches/<batch>-<topic>.md` — the actual location is the `batchDoc` passed at spawn, never hard-coded.
|
|
155
155
|
**Six-segment map (one segment, one author)**: §1 discussion = the main agent · §2 batch task & design = eng-designer · §3 design review findings = the review subagent (advisor) · §4 user approval = the main agent · §5 implementation record = eng-coder · §6 verification & closeout = the main agent (the parent, as seen by subagents).
|
|
156
|
-
**Writing means**: `batch_segment` — **no path parameter** (the target record is bound to you at spawn); **the segment number follows from your identity**; if the write does not land ⇒ say "§× 未写入" plainly in your report.
|
|
156
|
+
**Writing means**: the `batch` tool (transition alias `batch_segment` — same append executor) — **no path parameter** (the target record is bound to you at spawn); **the segment number follows from your identity**; if the write does not land ⇒ say "§× 未写入" plainly in your report.
|
|
157
157
|
**The task book itself**: `batchDoc` = the batch-record path = **your task book** (mandatory on every eng-designer / eng-coder spawn; **unreadable ⇒ refused** — do not execute, bounce it back).
|
|
158
158
|
**Structure authority**: segment structure / gates / lifecycle are defined by the project's own batch-record mechanism (this section gives the map only — no mechanism restatement).
|
|
@@ -36,6 +36,6 @@ the audit "out-of-list" criterion = changed AND not reported (silent overreach);
|
|
|
36
36
|
## 批次档纪律(六段自写 · 执行者拒收)
|
|
37
37
|
- **§5 由你自写**(**一段一作者**):§1 主 agent / §2 eng-designer / §3 评审子代理 / §4 主 agent / **§5 你** / §6 父代理——
|
|
38
38
|
交付摘要 / 决策透明表 / 审计与代码评审轮次与终态 / fix round,落**批次档 §5**,不靠父侧转述(转述 = 失真源)。
|
|
39
|
-
写入手段 = `
|
|
39
|
+
写入手段 = `batch` 工具 `batch({action:"append", segment, text})`(过渡别名 `batch_segment`——append 同执行体)(**无路径参数**——目标档 = 你 spawn 时的批次档绑定,段号由你的身份定:eng-coder → §5);
|
|
40
40
|
写不进去(拒/失败)→ 报告里明说“§5 未写入”——不得静默跳过,也不得假设父侧会代写。
|
|
41
41
|
- **执行者拒收**:查不到任务书(批次档 §2 / `batchDoc` 路径不可读)→ **不执行、打回**——不自行补造任务书往下干。
|
|
@@ -44,7 +44,7 @@ Boundary crossings are backstopped by **prompt discipline + main-agent content v
|
|
|
44
44
|
## Two deliverables (don't mix them up)
|
|
45
45
|
1. **The batch task**: this batch's covered requirement entries / entries explicitly NOT in this batch / affected files / acceptance criteria → **batch record §2** (append, don't rewrite §1) — **not written into the design doc** (one-shot content mixed into a long-term doc would be overwritten by the next batch).
|
|
46
46
|
Six-segment boundary = **one author per segment**: §1 main agent / **§2 you** / §3 review subagent / §4 main agent / §5 eng-coder / §6 parent — you write only §2; subagents self-write, never via parent paraphrase.
|
|
47
|
-
Write means = `
|
|
47
|
+
Write means = the `batch` tool, `batch({action:"append", segment, text})` (transition alias `batch_segment` — same append executor) (**no path parameter** — the target doc is bound at your spawn, the segment number is determined by your identity: eng-designer → §2); write fails (refused/failed) → the report states "§2 未写入" (not written).
|
|
48
48
|
2. **The design doc** — next section.
|
|
49
49
|
- **Don't self-pick unassigned work**: the dispatch already scoped this round's task surface ⇒ **do not** switch to similar but unassigned work (especially "machine-check line folding / count corrections / closure statements" — already done in prior rounds).
|
|
50
50
|
|
|
@@ -40,10 +40,13 @@ Batch record, dispatch task books, verification conclusions, review firing, requ
|
|
|
40
40
|
> While stopped you **keep answering and discussing, presenting current results** — you just don't auto-cross the next step (spawn / review firing / doc advancement / post-digest
|
|
41
41
|
> follow-up actions all hold for the nod). The user's next explicit instruction ("ok / continue / start" or a specific next-step order) resumes advancement — prior state is not lost.
|
|
42
42
|
|
|
43
|
-
## Project state file (PROJECT-MANIFEST —
|
|
44
|
-
|
|
45
|
-
**
|
|
46
|
-
**
|
|
43
|
+
## Project state file (PROJECT-MANIFEST — resolved per use site)
|
|
44
|
+
**One per project** — a project = a directory carrying `PROJECT-MANIFEST.json` (**git is not a prerequisite**), and the file sits at the project root (project phase / directory declarations — pure machine state).
|
|
45
|
+
**Parameters are resolved per use site; a session is not bound to a project**: whichever project's path an action targets, that project's file is the one read (several projects coexisting = a normal state).
|
|
46
|
+
**Ownership = nearest ancestor wins**: walk the target path's ancestor chain and take the **nearest directory carrying the file** — when nested, **the child wins over the root** (an independent sub-project inside a root project keeps its own), never across siblings, no global priority question.
|
|
47
|
+
**Project-root judgment (walk DOWN only, never up; direct child directories only, no recursion)**: ① the anchor itself carries the file → the anchor is a project; ② the anchor itself contains `.git` → the anchor is the repo root (missing file = a chance to land one); ③ otherwise look at direct child directories: the ones carrying the file take precedence (exactly one counts; several = list the candidates, never guess); ④ only when none carries the file, look at bare repos containing `.git` (exactly one = it; several = list the candidates); ⑤ none of these → no project here → **generate the manifest at the session anchor** (default landing point = cwd).
|
|
48
|
+
**Landing one is a light action at the point of use**: one per project, each landed on its own; never pre-created, never batch-created. `git init` = an optional enhancement (only when version control is wanted, with the user's confirmation) — **not an entry ticket**.
|
|
49
|
+
**Never brick the session**: a session can start in any directory; when project parameters are needed and there is no project → **that action / that turn reports it**, without going silent and without killing the session. Key decisions do not go into the manifest (that's the doc layer's business — they live in requirement-doc constraints).
|
|
47
50
|
|
|
48
51
|
## Batch-record lifecycle (five rules)
|
|
49
52
|
1. **One implementation round's boundary**: one batch = one delivery target + one set of same-batch entries + one batch record; **any hit starts a new batch and a new record** — ① delivery target changed (topic word changed) ② stage crossed ③ this batch's entry set changed (new entries outside the original batch's scope / original entries all settled) — **in-scope ones ⇒ same batch continues**; boundary judgment rights are yours (main agent).
|
|
@@ -70,7 +73,7 @@ Engineering mode **must have it first** — **one per git repository**, located
|
|
|
70
73
|
## Review convergence (firing / adjudication / rounds)
|
|
71
74
|
- Firing rights: design reviews can only be fired by the user — you prepare and remind, the user fires;
|
|
72
75
|
delivery code review = an automatic flow node (subagent-internal protocol) — parent-side advisor = optional second opinion.
|
|
73
|
-
- Design review while a batch record is in flight: **must pass `batchDoc`** (the batch record path) — the reviewer thereby gets the `
|
|
76
|
+
- Design review while a batch record is in flight: **must pass `batchDoc`** (the batch record path) — the reviewer thereby gets the `batch` tool write channel (transition alias `batch_segment` — same append executor) and writes the findings table + VERDICT + counts **verbatim** into batch record §3;
|
|
74
77
|
an in-flight design review without a batch record is **not blocked** (no param = not mounted — never refuse a review for a missing param; without the write channel §3 can only be ghost-written by you and **marked**).
|
|
75
78
|
- Adjudication table: after every advisor review, reply with the response table — header exactly `| # | Action | Detail |`,
|
|
76
79
|
one row per issue; `#` = the advisor's issue number (`Orig#` from round 2 on).
|
package/session-gc.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* session-gc.mjs — 会话目录残留 GC + 冷 cwd
|
|
2
|
+
* session-gc.mjs — 会话目录残留 GC + 冷 cwd / 存量组报告与回收(SESSION.md §6.12 · §6.17,2026-09-06)。
|
|
3
3
|
*
|
|
4
4
|
* F1 残留 GC(自动):进程启动时对当前 cwd hash 前缀做一次轻量清理——.corrupted /
|
|
5
5
|
* .unreadable / .manifest.corrupted / .bak-* 保留 30 天,孤儿 .tmp 保留 7 天
|
|
@@ -7,27 +7,34 @@
|
|
|
7
7
|
* 安全(N1):活跃槽(主文件在 + manifest slotSessions[N] 属主活)的现场一律保留;
|
|
8
8
|
* manifest 主文件 / end marker / 数据主文件永不进入候选(后缀预过滤只匹配残留后缀)。
|
|
9
9
|
*
|
|
10
|
-
* F2 冷 cwd(手动——12.2.4 三步,v1 不自动删 manifest
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* F2 冷 cwd(手动——12.2.4 三步,v1 不自动删 manifest):判定 = manifest mtime 距今 > 90 天
|
|
11
|
+
* 且无任何活跃数据文件(.json.N 不存在或全部属死主);报告 = `session gc --dry-run`;
|
|
12
|
+
* 回收 = `session gc --confirm <hash|--all>`(整前缀——manifest + end marker + 死主数据 +
|
|
13
|
+
* 裸 v1 `{hash}.json` + 残留;回收前重校验冷态——TOCTOU 防护 T12)。
|
|
14
|
+
*
|
|
15
|
+
* STARTUP-LATENCY 批(2026-09-21 · §6.17):① 全链异步化(D-SE34——本档零同步扫描:
|
|
16
|
+
* `readdirSync`/`statSync`/`readFileSync`/`unlinkSync`/`rmSync` 一律退场,目录与逐文件面走
|
|
17
|
+
* `node:fs/promises`;同步 fs 只留 `existsSync` 单条目探测)② 存量组面(三合取判据 / 回收 /
|
|
18
|
+
* 清运 / 有界扫——住 `session-stale.mjs`,**启动窗外延迟拍**每进程每前缀一次、总评估 ≤ STALE_SWEEP_LIMIT)
|
|
19
|
+
* ③ 删除改**回收目录**(同卷 rename——判据窗 7 天 + 回收 7 天 ⇒ 不可逆 ≥14 天;D-SE36)
|
|
20
|
+
* ④ 显式命令面候选面自然扩大(冷 cwd ∪ 三合取存量组——零新增旗标)。
|
|
21
|
+
* 触发形态(D-SE39 · 2026-09-21 微修)= 核侧 `setTimeout`(`GC_PASS_DELAY_MS` = 3s,自调度点起
|
|
22
|
+
* ——pass 起点落于启动窗(TTY 门 ≤2s)之外);**异步非阻塞**、不 unref(保后台排空现状)。
|
|
16
23
|
*
|
|
17
24
|
* 拆分理由(§6.12 review #1):session-slots.mjs 已在 500 行硬限零余量,GC 是独立决策
|
|
18
|
-
|
|
19
|
-
*
|
|
25
|
+
*("何时清何种残留"),独立成模块;原语(sessionPath / ownerPid)自 session-slots import,
|
|
26
|
+
* 启动钩子(session.mjs resumeSlot 包装)只一行调用。
|
|
20
27
|
*
|
|
21
28
|
* F-MI7(2026-09-18):判活 = **核探测束**(`process-probe.mjs` `probeOwnersAsync`)+ `ownerState`
|
|
22
|
-
* 三态查表——本档**零逐 pid 探测**;**未知 ⇒ 保留 / 不判冷**(D-MI10
|
|
23
|
-
* 延后执行,不阻塞启动路径)。死主数据文件 / 活跃槽判定与认领面同源判据。
|
|
29
|
+
* 三态查表——本档**零逐 pid 探测**;**未知 ⇒ 保留 / 不判冷**(D-MI10 同向);入口面异步。
|
|
24
30
|
*/
|
|
25
|
-
|
|
26
|
-
import {
|
|
31
|
+
import { existsSync } from "node:fs"
|
|
32
|
+
import { readdir, readFile, stat, unlink } from "node:fs/promises"
|
|
27
33
|
import { join, dirname, basename } from "node:path"
|
|
28
34
|
import { configDir } from "./config.mjs"
|
|
29
35
|
import { sessionPath, ownerPid } from "./session-slots.mjs"
|
|
30
36
|
import { probeOwnersAsync, ownerState } from "./process-probe.mjs"
|
|
37
|
+
import { listStaleCwds, deleteStaleCwd, recycleGroup, sweepStale, trashRootFor } from "./session-stale.mjs"
|
|
31
38
|
|
|
32
39
|
/** 保留期(§6.12):损坏现场(.corrupted/.unreadable/.manifest.corrupted)与并发轮转
|
|
33
40
|
* 备份(.bak-*)30 天;孤儿 .tmp 7 天(崩溃现场恢复窗口)。 */
|
|
@@ -52,13 +59,13 @@ function classifyResidue(name, prefix) {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/** 活跃槽集合(§6.12 T3/T12 操作定义):.json.N 主文件存在 且 manifest slotSessions[N]
|
|
55
|
-
* 属主进程存活。manifest 缺失/损坏 →
|
|
62
|
+
* 属主进程存活。manifest 缺失/损坏 → 空集(不阻碍残留清理)。
|
|
56
63
|
* 探测 = **入口一次异步束**(零逐 pid exec);三态消费:活 ⇒ 活跃;死 ⇒ 非活跃;
|
|
57
64
|
* **未知(探测失败 / 缺行)⇒ 计为活跃**(现场保留——D-MI10 同向)。 */
|
|
58
65
|
async function liveSlots(dir, prefix, probeFn) {
|
|
59
66
|
const active = new Set()
|
|
60
67
|
let m
|
|
61
|
-
try { m = JSON.parse(
|
|
68
|
+
try { m = JSON.parse(await readFile(join(dir, `${prefix}.manifest`), "utf8")) } catch { return active }
|
|
62
69
|
const entries = []
|
|
63
70
|
for (const [n, owner] of Object.entries(m.slotSessions ?? {})) {
|
|
64
71
|
if (!/^\d+$/.test(n) || !owner) continue
|
|
@@ -78,14 +85,17 @@ async function liveSlots(dir, prefix, probeFn) {
|
|
|
78
85
|
* 残留 GC(§6.12——单 cwd 前缀,不跨 cwd 扫描):返回 { candidates, deleted }
|
|
79
86
|
* (dryRun 时 candidates 照列、deleted 为空——只列不删,N2 可预览)。
|
|
80
87
|
* 扫描先按后缀预过滤(N4——只 stat 残留候选),再对候选做活跃槽/孤儿/保留期判定;
|
|
81
|
-
* 无残留候选 ⇒ **零探测早退**(不发起探测束)。probeFn =
|
|
88
|
+
* 无残留候选 ⇒ **零探测早退**(不发起探测束)。probeFn = 测试注入缝(缺省核异步束);
|
|
89
|
+
* `entries` = 共享目录快照(启动 pass 只 readdir 一次——缺省自行读)。
|
|
82
90
|
*/
|
|
83
|
-
export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(), dryRun = false, probeFn = probeOwnersAsync } = {}) {
|
|
91
|
+
export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(), dryRun = false, probeFn = probeOwnersAsync, entries = null } = {}) {
|
|
84
92
|
const result = { candidates: [], deleted: [] }
|
|
85
|
-
let entries
|
|
86
|
-
|
|
93
|
+
let names = entries
|
|
94
|
+
if (!names) {
|
|
95
|
+
try { names = await readdir(dir) } catch { return result }
|
|
96
|
+
}
|
|
87
97
|
const candidates = []
|
|
88
|
-
for (const name of
|
|
98
|
+
for (const name of names) {
|
|
89
99
|
const c = classifyResidue(name, prefix)
|
|
90
100
|
if (c) candidates.push({ name, ...c })
|
|
91
101
|
}
|
|
@@ -96,28 +106,48 @@ export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(),
|
|
|
96
106
|
const p = join(dir, c.name)
|
|
97
107
|
if (c.tmp && existsSync(p.slice(0, -".tmp".length))) continue // 非孤儿 .tmp(主文件在——写中/回退候选)
|
|
98
108
|
let st
|
|
99
|
-
try { st =
|
|
109
|
+
try { st = await stat(p) } catch { continue }
|
|
100
110
|
if (st.mtimeMs >= now - c.retention) continue // 边界:older-than 才删,等于保留期保留(§6.12)
|
|
101
111
|
result.candidates.push(c.name)
|
|
102
112
|
if (!dryRun) {
|
|
103
|
-
try {
|
|
113
|
+
try { await unlink(p); result.deleted.push(c.name) } catch { /* 占用/竞态——跳过 */ }
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
return result
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/** 启动**窗外延迟拍** pass(§6.17 编排——点火见 `scheduleSessionGC`):① 一次**异步目录快照**
|
|
120
|
+
* (readdir 恰一次——两面共享)② 残留面(gcResidue 既有判据)③ 存量面(总评估有界
|
|
121
|
+
* ≤ STALE_SWEEP_LIMIT(含 ① 面 manifest 读——D-SE40)+ 超期回收批清运)。
|
|
122
|
+
* 目录缺失 / 不可读 ⇒ 两面各自降级(零副作用)。 */
|
|
123
|
+
async function gcPass(dir, prefix) {
|
|
124
|
+
let entries = null
|
|
125
|
+
try { entries = await readdir(dir) } catch { /* 降级:两面各自再试或不做事 */ }
|
|
126
|
+
await gcResidue({ dir, prefix, entries }).catch(() => { /* 清理失败静默——不影响主流程 */ })
|
|
127
|
+
await sweepStale({ dir, entries }).catch(() => { /* 同上 */ })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 启动窗外延迟拍的延迟(D-SE39):自调度点起 3s——pass 起点落于启动窗(TTY 门 ≤2s)之外。 */
|
|
131
|
+
export const GC_PASS_DELAY_MS = 3000
|
|
132
|
+
let gcPassDelayMs = GC_PASS_DELAY_MS
|
|
133
|
+
|
|
134
|
+
/** 测试缝(`_setSessionsDirForTest` 同款):用例置 0–短值即可点火(勿真等 3s);
|
|
135
|
+
* 还原 = `_setSessionGcDelayForTest(GC_PASS_DELAY_MS)`。 */
|
|
136
|
+
export function _setSessionGcDelayForTest(ms) { gcPassDelayMs = ms }
|
|
137
|
+
|
|
109
138
|
const scheduledPrefixes = new Set()
|
|
110
139
|
|
|
111
|
-
/** 启动钩子(§6.12 review #8——N4):
|
|
112
|
-
*
|
|
140
|
+
/** 启动钩子(§6.12 review #8——N4;触发形态 = **启动窗外延迟拍**,§6.17 D-SE39):
|
|
141
|
+
* GC 延后 `GC_PASS_DELAY_MS`(3s——自调度点起)点火 ⇒ 启动链不因 pass 竞争劣化;
|
|
142
|
+
* **异步非阻塞**;每进程每前缀一次(Set 去重——resumeSlot 可多次进入);**不 unref**
|
|
143
|
+
* (保后台排空现状)。
|
|
144
|
+
* F-SL1:pass 全链异步(D-SE34——零同步扫描,判据 = 启动路径同步 fs 阻塞 ≤50ms)。 */
|
|
113
145
|
export function scheduleSessionGC(cwd) {
|
|
114
146
|
let base
|
|
115
147
|
try { base = sessionPath(cwd) } catch { return }
|
|
116
148
|
if (scheduledPrefixes.has(base)) return
|
|
117
149
|
scheduledPrefixes.add(base)
|
|
118
|
-
|
|
119
|
-
gcResidue({ dir: dirname(base), prefix: basename(base) }).catch(() => { /* 清理失败静默——不影响主流程 */ })
|
|
120
|
-
})
|
|
150
|
+
setTimeout(() => { gcPass(dirname(base), basename(base)).catch(() => { /* 静默 */ }) }, gcPassDelayMs)
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
/**
|
|
@@ -127,20 +157,24 @@ export function scheduleSessionGC(cwd) {
|
|
|
127
157
|
* 含裸 v1 `{hash}.json`,§6.12 D-V1——dataFiles 仅 .json.N,v1 不参与冷态判定)。
|
|
128
158
|
* 探测 = **全部候选前缀一次异步束**(两遍:先筛候选并收 pid,再一次判活 + 三态);
|
|
129
159
|
* **未知 ⇒ 非冷(保留)**——探测失败不得判冷(D-MI10 同向)。
|
|
160
|
+
* `hashes` = 单组重校验(显式面 `--confirm <hash>`);`entries` = 共享快照。
|
|
130
161
|
*/
|
|
131
|
-
export async function listColdCwds({ dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync } = {}) {
|
|
132
|
-
let entries
|
|
133
|
-
|
|
162
|
+
export async function listColdCwds({ dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync, hashes = null, entries = null } = {}) {
|
|
163
|
+
let names = entries
|
|
164
|
+
if (!names) {
|
|
165
|
+
try { names = await readdir(dir) } catch { return [] }
|
|
166
|
+
}
|
|
134
167
|
const cands = []
|
|
135
|
-
for (const name of
|
|
168
|
+
for (const name of names) {
|
|
136
169
|
if (!name.endsWith(".json.manifest")) continue // end marker(.manifest.cli/.vscode)与 .manifest.corrupted 天然排除
|
|
137
170
|
const prefix = name.slice(0, -".manifest".length) // `${hash}.json`
|
|
171
|
+
if (hashes && !hashes.includes(prefix.slice(0, -".json".length))) continue
|
|
138
172
|
let st
|
|
139
|
-
try { st =
|
|
173
|
+
try { st = await stat(join(dir, name)) } catch { continue }
|
|
140
174
|
if (st.mtimeMs >= now - COLD_CWD_RETENTION_MS) continue // 近期活跃(<90 天阈值——T10)
|
|
141
175
|
let m
|
|
142
|
-
try { m = JSON.parse(
|
|
143
|
-
const dataFiles =
|
|
176
|
+
try { m = JSON.parse(await readFile(join(dir, name), "utf8")) } catch { continue } // 损坏 manifest 不判冷(.corrupted 归残留 GC)
|
|
177
|
+
const dataFiles = names.filter((e) => e.startsWith(prefix + ".") && /^\d+$/.test(e.slice(prefix.length + 1)))
|
|
144
178
|
const owners = dataFiles.map((e) => {
|
|
145
179
|
const slotName = e.slice(prefix.length + 1)
|
|
146
180
|
const pid = ownerPid(m.slotSessions?.[slotName] ?? "")
|
|
@@ -161,43 +195,39 @@ export async function listColdCwds({ dir = sessionsDir(), now = Date.now(), prob
|
|
|
161
195
|
prefix: c.prefix,
|
|
162
196
|
manifestMtime: c.manifestMtime,
|
|
163
197
|
dataFiles: c.dataFilesLen,
|
|
164
|
-
files:
|
|
198
|
+
files: names.filter((e) => e.startsWith(c.prefix + ".") || e === c.prefix), // 整前缀清空含裸 v1 {hash}.json(§6.12 D-V1)
|
|
165
199
|
})
|
|
166
200
|
}
|
|
167
201
|
return cold
|
|
168
202
|
}
|
|
169
203
|
|
|
170
204
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
205
|
+
* 回收指定可清组(§6.12 步骤 3 + §6.17 D-SE36):冷 cwd 面(90 天)优先——**2026-09-21 起经
|
|
206
|
+
* 回收目录**(逐文件 rename 进 `sessions-trash/<批次时间戳>/`;不可逆删除最早 = 最后写入 + 14 天,
|
|
207
|
+
* 恢复 = 移回原目录);非冷 ⇒ 三合取存量组面(同判据单组重校验)。两面前均重校验(TOCTOU——
|
|
208
|
+
* 期间变活 / 出窗 ⇒ 拒绝 `{ok:false, reason:"not-cold"}`,零删除)。`entries` = 目录名快照
|
|
209
|
+
*(可选——命令面循环传同一快照免逐组全目录 readdir;① 面 manifest 读与探测束仍逐组新鲜)。
|
|
174
210
|
*/
|
|
175
|
-
export async function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync } = {}) {
|
|
176
|
-
const target = (await listColdCwds({ dir, now, probeFn })).find((c) => c.hash === hash)
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
// §6.14(TUI-OOM-ROOTCAUSE):目录项(记录存储 sidecar `{prefix}.N.d`)递归删——
|
|
183
|
-
// 现 unlinkSync 对目录静默跳过(旧实现漏删 sidecar);数据文件维持 unlink。
|
|
184
|
-
if (statSync(p).isDirectory()) rmSync(p, { recursive: true, force: true })
|
|
185
|
-
else unlinkSync(p)
|
|
186
|
-
deleted.push(name)
|
|
187
|
-
} catch { /* 占用/竞态——跳过 */ }
|
|
211
|
+
export async function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync, entries = null } = {}) {
|
|
212
|
+
const target = (await listColdCwds({ dir, now, probeFn, hashes: [hash], entries })).find((c) => c.hash === hash)
|
|
213
|
+
if (target) {
|
|
214
|
+
const r = await recycleGroup(target, { dir, now })
|
|
215
|
+
if (!r.moved.length) return { ok: false, reason: "recycle-failed", deleted: [], skipped: r.skipped }
|
|
216
|
+
return { ok: true, reason: "cold-90d", deleted: r.moved, skipped: r.skipped, batch: r.batch }
|
|
188
217
|
}
|
|
189
|
-
return {
|
|
218
|
+
return deleteStaleCwd(hash, { dir, now, probeFn, entries }) // 三合取面(含 TOCTOU 重校验;不中 ⇒ not-cold)
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
/**
|
|
193
|
-
* ④ 端差段 ·
|
|
222
|
+
* ④ 端差段 · 显式执行面(§6.12 / §6.17 D-SE38)——命令行壳 `thincoder session gc` 与
|
|
223
|
+
* 端侧命令(`thincoder.sessionGc`——经数据面 API,不消费本函数)共同的数据面;
|
|
194
224
|
* 本段**核内零消费方**(结构机检③):核内保存实现(取一侧),命令接线属壳侧(S2)。
|
|
195
225
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* --dry-run 报告当前 cwd 残留候选 +
|
|
199
|
-
* --confirm <hash>
|
|
200
|
-
* --confirm --all
|
|
226
|
+
* 候选面(§6.17)= 冷 cwd(90 天冷判据——cwd 存活组唯一出口)∪ 三合取存量组(**全量面**——
|
|
227
|
+
* 显式面无 STALE_SWEEP_LIMIT 闸:② 面滞留的兜底)。删除 = 回收(可回退)。
|
|
228
|
+
* --dry-run 报告当前 cwd 残留候选 + 全部可清组候选(reason + 文件数;只列不删,N2 预览)
|
|
229
|
+
* --confirm <hash> 回收指定组整前缀(警告 + 文件清单 + TOCTOU 重校验)
|
|
230
|
+
* --confirm --all 逐候选同型回收
|
|
201
231
|
* 返回进程退出码(0/1)。dir/prefix/now/out/err/probeFn 为测试注入缝(默认生产行为)。
|
|
202
232
|
*/
|
|
203
233
|
export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, cwd = process.cwd(), now = Date.now(), out = console.log, err = console.error, probeFn = probeOwnersAsync } = {}) {
|
|
@@ -211,6 +241,16 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
|
|
|
211
241
|
return 1
|
|
212
242
|
}
|
|
213
243
|
|
|
244
|
+
// 单组面(`--confirm <hash>`):候选面收窄到该 hash(免全量 ② 评估);`--all` / `--dry-run` = 全量面
|
|
245
|
+
const hashFilter = dryRun || confirmTarget === "--all" ? null : [confirmTarget]
|
|
246
|
+
const cold = await listColdCwds({ dir, now, probeFn, hashes: hashFilter })
|
|
247
|
+
const stale = (await listStaleCwds({ dir, now, probeFn, limit: Infinity, hashes: hashFilter })).candidates
|
|
248
|
+
const coldHashes = new Set(cold.map((c) => c.hash)) // 冷面命中的组不重复列(并集——同一组两判据可达)
|
|
249
|
+
const candidates = [
|
|
250
|
+
...cold.map((c) => ({ ...c, reason: "cold-90d" })),
|
|
251
|
+
...stale.filter((c) => !coldHashes.has(c.hash)),
|
|
252
|
+
]
|
|
253
|
+
|
|
214
254
|
if (dryRun) {
|
|
215
255
|
out("Session GC dry-run — no files will be deleted.")
|
|
216
256
|
// 当前 cwd 残留(§6.12 删除面——自动 GC 的预览)
|
|
@@ -219,30 +259,34 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
|
|
|
219
259
|
const residue = p ? await gcResidue({ dir, prefix: p, now, dryRun: true, probeFn }) : { candidates: [] }
|
|
220
260
|
out(`Residue candidates for current project (${p ?? "unknown"}): ${residue.candidates.length}`)
|
|
221
261
|
for (const name of residue.candidates) out(` ${name}`)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (dryRun) {
|
|
226
|
-
out(`Cold project candidates (manifest idle > 90 days, no live data files): ${cold.length}`)
|
|
227
|
-
for (const c of cold) {
|
|
228
|
-
out(` ${c.hash} manifest mtime ${new Date(c.manifestMtime).toISOString()} data files ${c.dataFiles} total files ${c.files.length}`)
|
|
229
|
-
}
|
|
230
|
-
if (cold.length) out('Run "thincoder session gc --confirm <hash>" (or --confirm --all) to permanently delete a cold project prefix.')
|
|
262
|
+
out(`Cold/stale project candidates (cold = manifest idle > 90 days; stale = no live owner + cwd unreachable/empty + 7-day window): ${candidates.length}`)
|
|
263
|
+
for (const c of candidates) out(` ${c.hash} reason ${c.reason} files ${c.files.length}`)
|
|
264
|
+
if (candidates.length) out('Run "thincoder session gc --confirm <hash>" (or --confirm --all) to move a project prefix into the recycle bin (recoverable: sessions-trash/<timestamp>/, 7 days).')
|
|
231
265
|
return 0
|
|
232
266
|
}
|
|
233
267
|
|
|
234
|
-
// --confirm
|
|
235
|
-
|
|
268
|
+
// --confirm:回收前警告(N2 可逆——文件清单 + 回收提示);deleteColdCwd 内部重校验(TOCTOU)。
|
|
269
|
+
// 一次性目录快照(逐组重校验复用——同自动面 pass 模型;① 面 manifest 读与探测束仍逐组新鲜)。
|
|
270
|
+
let loopEntries = null
|
|
271
|
+
if (confirmTarget === "--all") {
|
|
272
|
+
try { loopEntries = await readdir(dir) } catch { loopEntries = null }
|
|
273
|
+
}
|
|
274
|
+
const targets = confirmTarget === "--all" ? candidates : candidates.filter((c) => c.hash === confirmTarget)
|
|
236
275
|
if (!targets.length) {
|
|
237
|
-
err(`Refused: ${confirmTarget} is not a cold project (active, recent, or unknown) — nothing deleted.`)
|
|
276
|
+
err(`Refused: ${confirmTarget} is not a cold/stale project (active, recent, or unknown) — nothing deleted.`)
|
|
238
277
|
return 1
|
|
239
278
|
}
|
|
240
279
|
for (const t of targets) {
|
|
241
|
-
out(`WARNING:
|
|
280
|
+
out(`WARNING: 此操作将回收该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files) → ${trashRootFor(dir)}:`)
|
|
242
281
|
for (const name of t.files) out(` ${name}`)
|
|
243
|
-
const r = await deleteColdCwd(t.hash, { dir, now, probeFn })
|
|
244
|
-
if (!r.ok) {
|
|
245
|
-
|
|
282
|
+
const r = await deleteColdCwd(t.hash, { dir, now, probeFn, entries: loopEntries })
|
|
283
|
+
if (!r.ok) {
|
|
284
|
+
err(r.reason === "recycle-failed"
|
|
285
|
+
? `Recycle failed for ${t.hash} — skipped (files kept in place).`
|
|
286
|
+
: `Refused: ${t.hash} is no longer cold (became active) — skipped.`)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
out(`Moved ${r.deleted.length} files for ${t.hash} into the recycle bin (${r.batch})${r.skipped?.length ? ` — skipped ${r.skipped.length} (locked/racing, kept in place)` : ""}.`)
|
|
246
290
|
}
|
|
247
291
|
return 0
|
|
248
292
|
}
|
package/session-lifecycle.mjs
CHANGED
|
@@ -29,11 +29,12 @@ import { loadSlotFile } from "./session.mjs"
|
|
|
29
29
|
import { mergeAdjacentAssistantEchoes } from "./context.mjs"
|
|
30
30
|
import { scheduleSessionGC } from "./session-gc.mjs"
|
|
31
31
|
import { restoreEngTokens } from "./token-ttl.mjs"
|
|
32
|
+
import { clearPlanMode } from "./agent-tools/plan.mjs"
|
|
32
33
|
import { bindRecordStore, unbindRecordStore, RECORD_WINDOW_MESSAGES } from "./session-store.mjs"
|
|
33
34
|
|
|
34
35
|
/** 恢复决策包装(SESSION.md §6.12 启动钩子,2026-09-06):本端恢复入口触发一次残留 GC——
|
|
35
|
-
* scheduleSessionGC
|
|
36
|
-
*
|
|
36
|
+
* scheduleSessionGC 内部**启动窗外延迟拍**(`GC_PASS_DELAY_MS` = 3s)+ 每进程每前缀去重,
|
|
37
|
+
* **异步非阻塞**启动路径(N4;§6.17 D-SE39);**async**(F-MI7——调用面必须 await)。 */
|
|
37
38
|
export async function resumeSlot(cwd) {
|
|
38
39
|
scheduleSessionGC(cwd)
|
|
39
40
|
return slotsResumeSlot(cwd)
|
|
@@ -112,6 +113,10 @@ export function applySession(agent, data, opts = {}) {
|
|
|
112
113
|
agent.config.agent ??= {}
|
|
113
114
|
agent.config.agent.engineering = data.engineering === true
|
|
114
115
|
}
|
|
116
|
+
// ENG-PLAN-EXCLUSION(FR31 ③ / KD10——恢复点①·CLI `applySession`):工程模式 ⇒ planMode 恒
|
|
117
|
+
// false(内存位 + 未注入的 plan 提示语)——防「工程纪律 × plan 只读」半状态随槽恢复复活。
|
|
118
|
+
// **槽不就地回写**:内存值在下一次 `saveSession` 随 `planMode` 字段自然收正(`session.mjs:128`)。
|
|
119
|
+
if (agent.config?.agent?.engineering === true) clearPlanMode(agent)
|
|
115
120
|
if (data.advisor) {
|
|
116
121
|
agent.config.advisor = { ...data.advisor }
|
|
117
122
|
}
|
|
@@ -166,8 +171,11 @@ export function applySession(agent, data, opts = {}) {
|
|
|
166
171
|
* write an empty session, and mark it as the active slot.
|
|
167
172
|
* No limit on the number of sessions.
|
|
168
173
|
* **async**(F-MI7:入口一次异步束——探测不阻塞事件循环;调用面必须 await)。
|
|
174
|
+
* `opts.releaseStale`(F-CR1 · SESSION.md §6.5 / §6.16):**调用面 opt-in**——CLI `/new` 传
|
|
175
|
+
* (`cmd-new.mjs`),释放本进程残留认领(保留集 = {新槽});ACP 四点不传(多会话多认领属其
|
|
176
|
+
* 设计——F-CR3 零回归)。
|
|
169
177
|
*/
|
|
170
|
-
export async function newSession(cwd) {
|
|
178
|
+
export async function newSession(cwd, opts = {}) {
|
|
171
179
|
const m = loadManifest(cwd)
|
|
172
180
|
|
|
173
181
|
// 2026-09-01 advisor 🔵:先清理死主条目(与 ensureActive 分支2 语义一致)——否则
|
|
@@ -223,7 +231,9 @@ export async function newSession(cwd) {
|
|
|
223
231
|
slots: deadSlots.filter((s) => !m.slots[s]),
|
|
224
232
|
}
|
|
225
233
|
: null
|
|
226
|
-
|
|
234
|
+
// F-CR1 认领释放(SESSION.md §6.5 / §6.16 公式代入):保留集 = {新槽}——与既有死主
|
|
235
|
+
// deletions 合流(同一一次 saveManifest;落盘判据见核 `saveManifest`)。
|
|
236
|
+
saveManifest(cwd, m, deletions, opts.releaseStale ? { setActive: true, release: [slot] } : { setActive: true })
|
|
227
237
|
// 2026-09-05 §6.10 D-4:/new 落点写本端记录(显式切换跟随——T-M8)
|
|
228
238
|
writeEndMarker(cwd, slot)
|
|
229
239
|
return slot
|
|
@@ -280,7 +290,10 @@ export function switchToSlot(cwd, slot) {
|
|
|
280
290
|
m.slotSessions ??= {}
|
|
281
291
|
m.slotSessions[slot] = getSessionId()
|
|
282
292
|
}
|
|
283
|
-
|
|
293
|
+
// F-CR1 认领释放(SESSION.md §6.5 / §6.16 公式代入):保留集 = 未占 ⇒ {目标};**被占 ⇒ 空**
|
|
294
|
+
// (不认领目标 + 旧认领一并释放——下次保存经 `allocateFresh` fork,既有语义)。核受占 =
|
|
295
|
+
// 切换成立(D-SE33:指针 / 记录按 D-6 / D-4 落点——fork 面依赖指针翻至目标槽)。
|
|
296
|
+
saveManifest(cwd, m, null, { setActive: true, release: occ.occupied ? [] : [slot] })
|
|
284
297
|
// 2026-09-05 §6.10 D-4:/session N 跟随"最后查看的槽"(成功切换才写)
|
|
285
298
|
writeEndMarker(cwd, slot)
|
|
286
299
|
return data
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `CORE-UNIFICATION.md` §2.8.1 表第 3 行随批执行外提;各函数语义**原样**迁移):
|
|
5
5
|
* 摘要面(`extractSlotMeta` / `slotDigest`)· 清单读写(`loadManifest` / `saveManifest`)·
|
|
6
6
|
* 属主面(`ownerPid` / `ownerPids` / `ownerStateOf` / `ownerArgs` / `cleanDeadOwners`)·
|
|
7
|
-
* 认领面(`ensureActive` / `allocateFresh` / `claimSlot` / `activeSlot
|
|
7
|
+
* 认领面(`ensureActive` / `allocateFresh` / `claimSlot` / `activeSlot` / **认领释放集
|
|
8
|
+
* `staleClaims`**——F-CR1 · SESSION-CLAIM 批:判据单源,`saveManifest` 落盘 + 各落点共用)。
|
|
8
9
|
*
|
|
9
10
|
* 静态环(与既有的 session.mjs ↔ session-slots.mjs 同形):本档引 `session-slots.mjs` 的
|
|
10
11
|
* 存储原语(`getSessionId` / `manifestPath` / `slotPath` / `writeSessionFile`),
|
|
@@ -69,6 +70,10 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
|
|
|
69
70
|
// (ensureActive 分支、newSession、switchToSlot、deleteSlot 删到 active 时)传
|
|
70
71
|
// opts.setActive;其余调用方(saveSession/ACP 认领/死项清理)默认保留磁盘 fresh 的
|
|
71
72
|
// active,否则毫秒窗口内会把并发方刚翻的 active 回滚(F1 防漂移的反向变体)。
|
|
73
|
+
// F-CR1 认领释放(判据单源 · SESSION.md §6.2 / §6.16 落盘判据三条):`opts.release` = 保留集
|
|
74
|
+
// (落点槽 ∪ 本进程其余活绑定槽;被占落点 ⇒ 空集)。③ 内存认领表先移除残留条目(否则下方
|
|
75
|
+
// 条目级合并会把它从内存复活回写)——先于 try(fresh 不可读时亦须生效)。
|
|
76
|
+
if (opts.release) dropStaleClaims(m, opts.release)
|
|
72
77
|
try {
|
|
73
78
|
const fresh = JSON.parse(readFileSync(manifestPath(cwd), "utf8"))
|
|
74
79
|
if (fresh && typeof fresh === "object" && fresh.slots && typeof fresh.slots === "object") {
|
|
@@ -77,6 +82,14 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
|
|
|
77
82
|
merged.slots = { ...fresh.slots, ...(m.slots ?? {}) }
|
|
78
83
|
merged.slotSessions = { ...(fresh.slotSessions ?? {}), ...(m.slotSessions ?? {}) }
|
|
79
84
|
if (m.sessionId) merged.sessionId = m.sessionId
|
|
85
|
+
// ① 释放集按**本次 fresh 快照**取值(不得以陈旧内存 manifest 构 deletions);② **值条件删除**
|
|
86
|
+
// (仅当 fresh 属主仍为本进程——窗口内他人的新认领不在集内、不被误删)⇒ 并入本次 deletions。
|
|
87
|
+
if (opts.release) {
|
|
88
|
+
const released = staleClaims(fresh, opts.release)
|
|
89
|
+
if (released.length > 0) {
|
|
90
|
+
deletions = { ...(deletions ?? {}), slotSessions: [...(deletions?.slotSessions ?? []), ...released] }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
80
93
|
if (deletions) {
|
|
81
94
|
for (const [section, keys] of Object.entries(deletions)) {
|
|
82
95
|
for (const k of keys) delete merged[section]?.[k]
|
|
@@ -95,6 +108,23 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
|
|
|
95
108
|
writeSessionFile(manifestPath(cwd), m)
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
/** 本进程**残留认领集**(F-CR1 释放谓词 —— **判据单源** · SESSION.md §6.2 / §6.16):
|
|
112
|
+
* 「释放 A ⟺ `slotSessions[A]` 为本进程 ∧ `A ∉ 保留集`」。`keep` = 保留集 = 落点槽 ∪ 本进程
|
|
113
|
+
* 其余活绑定槽(单绑定落点 ⇒ `{落点槽}`;被占落点 ⇒ 空集)。返回槽号(**字符串键**形态)。 */
|
|
114
|
+
export function staleClaims(src, keep = []) {
|
|
115
|
+
const keepSet = new Set((keep ?? []).filter((s) => s != null).map(Number))
|
|
116
|
+
const mySessionId = getSessionId()
|
|
117
|
+
return Object.entries(src?.slotSessions ?? {})
|
|
118
|
+
.filter(([slot, owner]) => owner === mySessionId && !keepSet.has(Number(slot)))
|
|
119
|
+
.map(([slot]) => slot)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 从**内存**认领表移除本进程残留条目(判据③——与写盘同一时机;见 `saveManifest`)。 */
|
|
123
|
+
function dropStaleClaims(m, keep) {
|
|
124
|
+
if (!m?.slotSessions) return
|
|
125
|
+
for (const slot of staleClaims(m, keep)) delete m.slotSessions[slot]
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
/** 属主串 → pid(`"pid-ts-rand"`——`owner.split("-")[0]` 格式判据单源:session-slots ↔
|
|
99
129
|
* session-lifecycle 共用,防两处漂移)。不可解析 ⇒ NaN——判据层按 dead 处理
|
|
100
130
|
* (见 process-probe `ownerState`)。 */
|
|
@@ -248,12 +278,15 @@ export function allocateFresh(cwd, m, deadParam = null, bundle = null) {
|
|
|
248
278
|
* 已按"属主 空/死/本进程"判据校验可用性;幂等)。manifest active 保留为共享指针(D-6:
|
|
249
279
|
* 旧版端/ACP 恢复依据 + 无记录端一次性继承源 + 列表回退高亮——不再作本端恢复第一依据)。
|
|
250
280
|
* deadParam 在写入所有权之后求值(防 deletions 删掉本调用刚认领的槽——ensureActive
|
|
251
|
-
* deadParam 同型过滤)。
|
|
281
|
+
* deadParam 同型过滤)。
|
|
282
|
+
* F-CR1(2026-09-21 · SESSION-CLAIM 批):认领落点释放本进程残留认领(保留集 = {认领槽}——
|
|
283
|
+
* §6.2 公式代入);本函数 = 启动恢复 / 本端恢复落点(核 `resumeSlot` 与 VSC 端壳
|
|
284
|
+
* `resumeSlot` 共用),落盘判据(fresh 快照 / 值条件删除 / 内存移除)见 `saveManifest`。 */
|
|
252
285
|
export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
|
|
253
286
|
m.slotSessions ??= {}
|
|
254
287
|
m.slotSessions[slot] = getSessionId()
|
|
255
288
|
m.active = slot // 认领即翻共享指针(setActive 写 m.active——不更新则落快照旧值)
|
|
256
|
-
saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
|
|
289
|
+
saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true, release: [slot] })
|
|
257
290
|
}
|
|
258
291
|
|
|
259
292
|
/** Return the active slot number for this process, claiming one if necessary */
|