@lmzhen/dsh-evolution-core 0.27.3 → 0.29.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.
@@ -50,9 +50,48 @@ export interface ChannelSpec<T = undefined> {
50
50
  readonly signal?: AbortSignal;
51
51
  readonly afterResult?: (child: Agent) => T;
52
52
  }
53
+ /** Open one child for a multi-turn conversation. Round 1 is the prompt. */
54
+ export interface ConversationOpenSpec {
55
+ readonly parent: Agent;
56
+ readonly label: string;
57
+ readonly promptText: string;
58
+ readonly kind: ChannelKind;
59
+ readonly allow: readonly string[];
60
+ readonly cwd?: string;
61
+ readonly agentOptions?: AgentOptions;
62
+ readonly maxDepth?: number;
63
+ readonly timeoutMs: number;
64
+ readonly signal?: AbortSignal;
65
+ }
66
+ export interface ConversationTurnOptions {
67
+ readonly timeoutMs?: number;
68
+ readonly signal?: AbortSignal;
69
+ }
70
+ export interface ConversationTurn {
71
+ readonly childId: SessionId;
72
+ readonly stopReason: SubagentStopReason;
73
+ readonly text: string;
74
+ readonly structured?: unknown;
75
+ readonly diagnostic?: string;
76
+ }
77
+ export type ConversationOutcome = {
78
+ readonly kind: 'ok';
79
+ readonly childId: SessionId;
80
+ readonly turn: ConversationTurn;
81
+ } | {
82
+ readonly kind: 'unavailable';
83
+ readonly code: ChannelCode;
84
+ readonly reason: string;
85
+ } | {
86
+ readonly kind: 'aborted';
87
+ };
53
88
  export interface SubagentChannel {
54
89
  readonly state: ChannelState;
55
90
  prepare(spec: Pick<ChannelSpec, 'kind' | 'allow'>): Promise<ChannelState>;
56
91
  run<T = undefined>(spec: ChannelSpec<T>): Promise<ChannelOutcome<T>>;
92
+ /** Optional during the staged rollout: open/continue/dispose are the two-turn path. */
93
+ open?(spec: ConversationOpenSpec): Promise<ConversationOutcome>;
94
+ continueTurn?(childId: SessionId, promptText: string, options?: ConversationTurnOptions): Promise<ConversationOutcome>;
95
+ dispose?(childId: SessionId): Promise<void>;
57
96
  }
58
97
  //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,51 @@
1
+ import type { EvolutionIoLike } from './io.ts';
2
+ export interface TreeLockRecord {
3
+ readonly token: string;
4
+ readonly pid: number;
5
+ readonly startedAt: number;
6
+ }
7
+ export interface TreeLock {
8
+ readonly token: string;
9
+ readonly path: string;
10
+ readonly acquiredAt: number;
11
+ release(): Promise<void>;
12
+ }
13
+ export type TreeLockAcquireResult = {
14
+ readonly ok: true;
15
+ readonly lock: TreeLock;
16
+ } | {
17
+ readonly ok: false;
18
+ readonly reason: 'busy' | 'io';
19
+ readonly holder?: TreeLockRecord;
20
+ readonly message?: string;
21
+ };
22
+ export interface TreeLockOptions {
23
+ readonly io: EvolutionIoLike;
24
+ /** Evolution home; the lock lives at <home>/merge/.tree-lock. */
25
+ readonly home: string;
26
+ /** How long to wait for a busy lock. Default 5000 ms. */
27
+ readonly waitMs?: number;
28
+ /** Poll interval. Default 25 ms. */
29
+ readonly pollMs?: number;
30
+ readonly now?: () => number;
31
+ readonly sleep?: (ms: number) => Promise<void>;
32
+ }
33
+ /** The one lock path. */
34
+ export declare function treeLockFile(home: string): string;
35
+ /** Read the current holder without creating or changing anything. */
36
+ export declare function readTreeLock(io: EvolutionIoLike, home: string): Promise<TreeLockRecord | undefined>;
37
+ /**
38
+ * Acquire the tree write lock, waiting up to `waitMs`.
39
+ *
40
+ * A live holder is never stolen. A record whose pid is gone (or a corrupt record) is
41
+ * taken over; that is how a killed process releases its lock without a timer.
42
+ */
43
+ export declare function acquireTreeWriteLock(options: TreeLockOptions): Promise<TreeLockAcquireResult>;
44
+ /** Acquire, run, release; the lock is released even when the body throws. */
45
+ export declare function withTreeWriteLock<T>(options: TreeLockOptions, body: (lock: TreeLock) => Promise<T>): Promise<{
46
+ ok: true;
47
+ value: T;
48
+ } | Exclude<TreeLockAcquireResult, {
49
+ ok: true;
50
+ }>>;
51
+ //# sourceMappingURL=tree-lock.d.ts.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Cross-process tree write lock.
3
+ *
4
+ * The merge run holds this lock for its whole lifetime; curator's pass takes the same
5
+ * lock. The lock is a cooperative file record, created through `transactIo` so two
6
+ * processes cannot both win the create. A holder whose pid is gone is stale and may be
7
+ * taken over; a live pid is always respected, even for a long run (a live long run is
8
+ * exactly what the lock protects).
9
+ *
10
+ * `commitMerge` deliberately does NOT take this lock itself: the run shell owns it, so a
11
+ * run and its commit share one acquisition. Taking it inside the store would also
12
+ * deadlock the same-process curator path.
13
+ */
14
+ import { randomUUID } from 'node:crypto';
15
+ import { join } from 'node:path';
16
+ import { isProcessAlive, transactIo } from "./io.js";
17
+ /** The one lock path. */
18
+ export function treeLockFile(home) {
19
+ return join(home, 'merge', '.tree-lock');
20
+ }
21
+ function parseLock(raw) {
22
+ if (raw === null)
23
+ return undefined;
24
+ try {
25
+ const value = JSON.parse(raw);
26
+ if (typeof value.token !== 'string' || typeof value.pid !== 'number' || typeof value.startedAt !== 'number')
27
+ return undefined;
28
+ return { token: value.token, pid: value.pid, startedAt: value.startedAt };
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ const sleepDefault = (ms) => new Promise(resolve => setTimeout(resolve, ms));
35
+ /** Read the current holder without creating or changing anything. */
36
+ export async function readTreeLock(io, home) {
37
+ return parseLock(await io.readText(treeLockFile(home)));
38
+ }
39
+ /** Try once. A stale record (dead pid or unparsable) is replaced. */
40
+ async function tryAcquire(io, home, now) {
41
+ const path = treeLockFile(home);
42
+ let token;
43
+ let holder;
44
+ await transactIo(io, path, (current) => {
45
+ const existing = parseLock(current);
46
+ if (existing !== undefined && isProcessAlive(existing.pid)) {
47
+ holder = existing;
48
+ return current;
49
+ }
50
+ // Absent, corrupt, our own stale row, or a dead owner: take it.
51
+ token = randomUUID();
52
+ return JSON.stringify({ token, pid: process.pid, startedAt: now() });
53
+ });
54
+ return token === undefined ? (holder === undefined ? {} : { holder }) : { token };
55
+ }
56
+ /**
57
+ * Acquire the tree write lock, waiting up to `waitMs`.
58
+ *
59
+ * A live holder is never stolen. A record whose pid is gone (or a corrupt record) is
60
+ * taken over; that is how a killed process releases its lock without a timer.
61
+ */
62
+ export async function acquireTreeWriteLock(options) {
63
+ const now = options.now ?? (() => Date.now());
64
+ const waitMs = options.waitMs ?? 5_000;
65
+ const pollMs = options.pollMs ?? 25;
66
+ const sleep = options.sleep ?? sleepDefault;
67
+ const deadline = now() + Math.max(0, waitMs);
68
+ let lastHolder;
69
+ try {
70
+ for (;;) {
71
+ const attempt = await tryAcquire(options.io, options.home, now);
72
+ if (attempt.token !== undefined) {
73
+ const token = attempt.token;
74
+ const path = treeLockFile(options.home);
75
+ let released = false;
76
+ const lock = {
77
+ token,
78
+ path,
79
+ acquiredAt: now(),
80
+ async release() {
81
+ if (released)
82
+ return;
83
+ released = true;
84
+ await transactIo(options.io, path, (current) => {
85
+ const existing = parseLock(current);
86
+ return existing?.token === token ? null : current;
87
+ });
88
+ },
89
+ };
90
+ return { ok: true, lock };
91
+ }
92
+ lastHolder = attempt.holder;
93
+ if (now() >= deadline)
94
+ break;
95
+ await sleep(pollMs);
96
+ }
97
+ return { ok: false, reason: 'busy', ...(lastHolder !== undefined ? { holder: lastHolder } : {}) };
98
+ }
99
+ catch (error) {
100
+ return { ok: false, reason: 'io', message: error instanceof Error ? error.message : String(error) };
101
+ }
102
+ }
103
+ /** Acquire, run, release; the lock is released even when the body throws. */
104
+ export async function withTreeWriteLock(options, body) {
105
+ const acquired = await acquireTreeWriteLock(options);
106
+ if (!acquired.ok)
107
+ return acquired;
108
+ try {
109
+ return { ok: true, value: await body(acquired.lock) };
110
+ }
111
+ finally {
112
+ await acquired.lock.release();
113
+ }
114
+ }
115
+ //# sourceMappingURL=tree-lock.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Cross-skill unit undo planner.
3
+ *
4
+ * The curator's single-skill undo already proves the rule: one path is described by
5
+ * (baseline, oldest after, newest after), and a path the unit created is removed. A merge
6
+ * unit adds two shapes:
7
+ *
8
+ * - a source that the merge ARCHIVED: undo restores it from .archive; it has no live ops;
9
+ * - a target the merge CREATED: undo archives the target (never hard-deletes it); it has no
10
+ * body op.
11
+ *
12
+ * The plan is pure; the executor resolves hashes to bytes and applies anchors. Any refusal
13
+ * means the caller writes nothing.
14
+ */
15
+ import { type SkillVersion } from './skill-history.ts';
16
+ export interface UnitUndoMember {
17
+ readonly skill: string;
18
+ /** Entries of this unit for this skill. */
19
+ readonly entries: readonly SkillVersion[];
20
+ readonly status: 'live' | 'archived' | 'missing';
21
+ /** Required when status is archived: the archived directory to restore from. */
22
+ readonly archivedPath?: string;
23
+ /** True when this unit created the skill (the target of a merge-into-new). */
24
+ readonly createdInUnit?: boolean;
25
+ }
26
+ export interface UnitUndoOp {
27
+ readonly skill: string;
28
+ /** '' means SKILL.md; otherwise a support path relative to the skill directory. */
29
+ readonly path: string;
30
+ readonly remove: boolean;
31
+ readonly preUnitHash?: string;
32
+ /** The bytes the unit left on disk; the executor's CAS anchor. */
33
+ readonly expectedHash: string;
34
+ }
35
+ export interface UnitUndoRestore {
36
+ readonly skill: string;
37
+ readonly archivedPath: string;
38
+ }
39
+ export interface UnitUndoPlan {
40
+ readonly ops: readonly UnitUndoOp[];
41
+ readonly restores: readonly UnitUndoRestore[];
42
+ /** New targets: undo archives them instead of writing a body. */
43
+ readonly archiveTargets: readonly string[];
44
+ readonly refusals: readonly string[];
45
+ }
46
+ /** Plan one unit across a live target and any archived sources. */
47
+ export declare function planUnitUndoAcross(members: readonly UnitUndoMember[]): UnitUndoPlan;
48
+ //# sourceMappingURL=unit-undo-plan.d.ts.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Cross-skill unit undo planner.
3
+ *
4
+ * The curator's single-skill undo already proves the rule: one path is described by
5
+ * (baseline, oldest after, newest after), and a path the unit created is removed. A merge
6
+ * unit adds two shapes:
7
+ *
8
+ * - a source that the merge ARCHIVED: undo restores it from .archive; it has no live ops;
9
+ * - a target the merge CREATED: undo archives the target (never hard-deletes it); it has no
10
+ * body op.
11
+ *
12
+ * The plan is pure; the executor resolves hashes to bytes and applies anchors. Any refusal
13
+ * means the caller writes nothing.
14
+ */
15
+ import { BASELINE_ACTION, entryTarget } from "./skill-history.js";
16
+ function slotsOf(entries) {
17
+ const body = {};
18
+ const support = new Map();
19
+ const refusals = [];
20
+ for (const entry of entries) {
21
+ const isSupport = entry.path !== undefined && entryTarget(entry, entries) === 'support';
22
+ if (!isSupport) {
23
+ if (entry.action === BASELINE_ACTION)
24
+ body.baseline ??= entry;
25
+ else {
26
+ body.oldest ??= entry;
27
+ body.newest = entry;
28
+ }
29
+ continue;
30
+ }
31
+ const path = entry.path;
32
+ const slot = support.get(path) ?? {};
33
+ support.set(path, slot);
34
+ if (entry.action === BASELINE_ACTION)
35
+ slot.baseline ??= entry;
36
+ else {
37
+ slot.oldest ??= entry;
38
+ slot.newest = entry;
39
+ }
40
+ }
41
+ for (const [path, slot] of support) {
42
+ if (slot.newest === undefined)
43
+ refusals.push(`${path}(单元里没有该路径的 after 条目)`);
44
+ }
45
+ return { body, support, refusals };
46
+ }
47
+ /** Plan one unit across a live target and any archived sources. */
48
+ export function planUnitUndoAcross(members) {
49
+ const ops = [];
50
+ const restores = [];
51
+ const archiveTargets = [];
52
+ const refusals = [];
53
+ for (const member of members) {
54
+ if (member.status === 'missing') {
55
+ refusals.push(`${member.skill}(成员技能不在活动树或归档里)`);
56
+ continue;
57
+ }
58
+ if (member.status === 'archived') {
59
+ if (member.archivedPath === undefined || member.archivedPath === '') {
60
+ refusals.push(`${member.skill}(归档成员缺少归档路径)`);
61
+ continue;
62
+ }
63
+ restores.push({ skill: member.skill, archivedPath: member.archivedPath });
64
+ continue;
65
+ }
66
+ if (member.createdInUnit === true) {
67
+ archiveTargets.push(member.skill);
68
+ continue;
69
+ }
70
+ const { body, support, refusals: slotRefusals } = slotsOf(member.entries);
71
+ refusals.push(...slotRefusals.map(reason => member.skill + ':' + reason));
72
+ for (const [path, slot] of support) {
73
+ if (slot.newest === undefined)
74
+ continue;
75
+ const preUnitHash = slot.baseline !== undefined ? slot.baseline.hash : slot.oldest?.beforeHash;
76
+ ops.push({
77
+ skill: member.skill,
78
+ path,
79
+ remove: preUnitHash === undefined,
80
+ ...(preUnitHash === undefined ? {} : { preUnitHash }),
81
+ expectedHash: slot.newest.hash,
82
+ });
83
+ }
84
+ if (body.newest !== undefined) {
85
+ const preUnitHash = body.baseline !== undefined ? body.baseline.hash : body.oldest?.beforeHash;
86
+ if (preUnitHash === undefined)
87
+ refusals.push(`${member.skill}:SKILL.md 缺少单元前字节`);
88
+ else
89
+ ops.push({ skill: member.skill, path: '', remove: false, preUnitHash, expectedHash: body.newest.hash });
90
+ }
91
+ }
92
+ return { ops, restores, archiveTargets, refusals };
93
+ }
94
+ //# sourceMappingURL=unit-undo-plan.js.map
@@ -0,0 +1,28 @@
1
+ import type { SkillActionResult } from './skill-store.ts';
2
+ import type { UnitUndoPlan } from './unit-undo-plan.ts';
3
+ export interface UnitUndoSink {
4
+ readSkill(skill: string): Promise<string | null>;
5
+ readSupport(skill: string, path: string): Promise<string | null>;
6
+ /** Read one blob by hash; null means the blob is gone. */
7
+ readBlob(hash: string): Promise<string | null>;
8
+ /** Optional archived-member probe; without it the restore itself is the first proof. */
9
+ hasArchived?(skill: string): Promise<boolean>;
10
+ restoreFromArchive(skill: string): Promise<SkillActionResult>;
11
+ archive(skill: string, reason: string): Promise<SkillActionResult>;
12
+ updateSkill(skill: string, body: string, anchor: {
13
+ sha256: string;
14
+ }): Promise<SkillActionResult>;
15
+ writeSupportFile(skill: string, path: string, content: string, anchor: {
16
+ sha256: string;
17
+ }): Promise<SkillActionResult>;
18
+ removeSupportFile(skill: string, path: string, anchor: {
19
+ sha256: string;
20
+ }): Promise<SkillActionResult>;
21
+ }
22
+ export interface UnitUndoExecution {
23
+ readonly ok: boolean;
24
+ readonly message: string;
25
+ readonly reverted: readonly string[];
26
+ }
27
+ export declare function executeUnitUndo(plan: UnitUndoPlan, sink: UnitUndoSink): Promise<UnitUndoExecution>;
28
+ //# sourceMappingURL=unit-undo.d.ts.map
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Cross-skill unit undo executor.
3
+ *
4
+ * Phase A is a zero-write preflight: every live path still carries the unit's after-bytes and
5
+ * every pre-unit blob is readable. Phase B restores archived members, applies support files,
6
+ * writes the body last, then archives a target the unit created. Any preflight refusal means
7
+ * no write at all.
8
+ */
9
+ import { contentHash } from "./mutations.js";
10
+ export async function executeUnitUndo(plan, sink) {
11
+ const refusals = [...plan.refusals];
12
+ const restores = [];
13
+ for (const restore of plan.restores) {
14
+ if (sink.hasArchived !== undefined) {
15
+ const present = await sink.hasArchived(restore.skill);
16
+ if (!present)
17
+ refusals.push(`${restore.skill}(归档成员不在 .archive)`);
18
+ else
19
+ restores.push(restore);
20
+ continue;
21
+ }
22
+ restores.push(restore);
23
+ }
24
+ const prepared = [];
25
+ for (const op of plan.ops) {
26
+ const live = op.path === '' ? await sink.readSkill(op.skill) : await sink.readSupport(op.skill, op.path);
27
+ if (live === null) {
28
+ refusals.push(`${op.skill}/${op.path === '' ? 'SKILL.md' : op.path}(盘上文件不存在)`);
29
+ continue;
30
+ }
31
+ if (contentHash(live) !== op.expectedHash) {
32
+ refusals.push(`${op.skill}/${op.path === '' ? 'SKILL.md' : op.path}(盘上字节已变,回退会覆盖别人的改动)`);
33
+ continue;
34
+ }
35
+ if (op.remove) {
36
+ prepared.push({ ...op, bytes: '', live });
37
+ continue;
38
+ }
39
+ const bytes = op.preUnitHash === undefined ? null : await sink.readBlob(op.preUnitHash);
40
+ if (bytes === null) {
41
+ refusals.push(`${op.skill}/${op.path === '' ? 'SKILL.md' : op.path}(单元前字节读不到:blob ${op.preUnitHash ?? '(missing)'})`);
42
+ continue;
43
+ }
44
+ prepared.push({ ...op, bytes, live });
45
+ }
46
+ if (refusals.length > 0) {
47
+ return { ok: false, message: `整单元撤销未开始(零写)—— 预检未通过:${refusals.join(';')}。修好后可重试。`, reverted: [] };
48
+ }
49
+ const reverted = [];
50
+ const done = () => reverted.length === 0 ? '无' : reverted.join(', ');
51
+ for (const restore of restores) {
52
+ const result = await sink.restoreFromArchive(restore.skill);
53
+ if (!result.ok)
54
+ return { ok: false, message: `恢复「${restore.skill}」失败:${result.message} —— 撤销未完成(已回退:${done()})。`, reverted };
55
+ reverted.push(restore.skill);
56
+ }
57
+ const supportOps = prepared.filter(op => op.path !== '');
58
+ const bodyOps = prepared.filter(op => op.path === '');
59
+ for (const op of supportOps) {
60
+ const result = op.remove
61
+ ? await sink.removeSupportFile(op.skill, op.path, { sha256: op.expectedHash })
62
+ : await sink.writeSupportFile(op.skill, op.path, op.bytes, { sha256: op.expectedHash });
63
+ if (!result.ok)
64
+ return { ok: false, message: `${op.skill}/${op.path}:${result.message} —— 撤销未完成(已回退:${done()})。`, reverted };
65
+ reverted.push(op.skill + '/' + op.path);
66
+ }
67
+ for (const op of bodyOps) {
68
+ const result = await sink.updateSkill(op.skill, op.bytes, { sha256: op.expectedHash });
69
+ if (!result.ok)
70
+ return { ok: false, message: `${op.skill}/SKILL.md:${result.message} —— 撤销未完成(已回退:${done()})。`, reverted };
71
+ reverted.push(op.skill + '/SKILL.md');
72
+ }
73
+ for (const skill of plan.archiveTargets) {
74
+ const result = await sink.archive(skill, 'merge undone');
75
+ if (!result.ok)
76
+ return { ok: false, message: `归档新目标「${skill}」失败:${result.message} —— 撤销未完成(已回退:${done()})。`, reverted };
77
+ reverted.push(skill);
78
+ }
79
+ return { ok: true, message: `已按单元撤销:${reverted.join(', ')}。`, reverted };
80
+ }
81
+ //# sourceMappingURL=unit-undo.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.27.3",
4
+ "version": "0.29.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -171,6 +171,42 @@
171
171
  "marker": "transactIo(io(), pathOf(PENDING_ARCHIVE_FILE)",
172
172
  "state": [],
173
173
  "note": "The resolved-pending archive (the legacy merge target). The store already declared its record map; v46 S1.12 declared the archive append as well — the second site in one file is exactly what the reverse scan exists to find."
174
+ },
175
+ {
176
+ "id": "merge-state",
177
+ "path": "<evolutionHome>/merge/merge-state.json",
178
+ "writer": "evolution-merge/src/state.ts",
179
+ "serializedBy": "transact",
180
+ "marker": "transactIo(io, mergeStatePath(home)",
181
+ "state": [],
182
+ "note": "The merge rows and their persisted round-one decisions. One transaction per mutation; reads stay pure projections."
183
+ },
184
+ {
185
+ "id": "merge-gate-cache",
186
+ "path": "<evolutionHome>/merge/gate-cache.json",
187
+ "writer": "evolution-merge/src/gate-cache.ts",
188
+ "serializedBy": "transact",
189
+ "marker": "transactIo(io, gateCachePath(home)",
190
+ "state": [],
191
+ "note": "The L0-L2 gate cache. A cache is still a durable write and goes through the same transaction seam."
192
+ },
193
+ {
194
+ "id": "merge-tree-lock-acquire",
195
+ "path": "<evolutionHome>/merge/.tree-lock",
196
+ "writer": "evolution-core/src/tree-lock.ts",
197
+ "serializedBy": "transact",
198
+ "marker": "transactIo(io, path",
199
+ "state": [],
200
+ "note": "The cooperative merge/curator tree lock create/replace. Two processes must not both win; the transaction is the lock."
201
+ },
202
+ {
203
+ "id": "merge-tree-lock-release",
204
+ "path": "<evolutionHome>/merge/.tree-lock",
205
+ "writer": "evolution-core/src/tree-lock.ts",
206
+ "serializedBy": "transact",
207
+ "marker": "transactIo(options.io, path",
208
+ "state": [],
209
+ "note": "The release half of the same lock: delete only when the recorded token still matches."
174
210
  }
175
211
  ]
176
212
  }