@yeaft/webchat-agent 0.1.225 → 0.1.227
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/conductor/conductor-claude.js +148 -129
- package/conductor/index.js +12 -10
- package/conductor/persistence.js +172 -136
- package/conductor/session.js +118 -265
- package/conductor/ui-messages.js +53 -52
- package/conductor/worktree.js +89 -82
- package/conductor.js +21 -32
- package/connection/message-router.js +10 -32
- package/package.json +1 -1
package/conductor/persistence.js
CHANGED
|
@@ -1,156 +1,219 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Conductor — 持久化管理
|
|
2
|
+
* Conductor — 持久化管理 (V5)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Conductor Home: ~/.config/yeaft-agent/.conductor/
|
|
5
|
+
* session.json — Conductor Claude session 元数据
|
|
6
|
+
* state.json — 全局 task 注册表
|
|
7
|
+
* messages.json — UI 消息(分片)
|
|
8
|
+
*
|
|
9
|
+
* Task 目录: {workDir}/.conductor/task-N/
|
|
10
|
+
* CLAUDE.md, memory.md, plan.json, status.json
|
|
11
|
+
* actors/{specialty}-{persona}/CLAUDE.md
|
|
7
12
|
*/
|
|
8
13
|
import { promises as fs } from 'fs';
|
|
9
14
|
import { join } from 'path';
|
|
10
|
-
import {
|
|
15
|
+
import { getConfigDir } from '../service.js';
|
|
16
|
+
import { createTaskWorktree } from './worktree.js';
|
|
11
17
|
|
|
12
18
|
// =====================================================================
|
|
13
|
-
// Conductor
|
|
19
|
+
// Conductor Home (Agent-level, singleton)
|
|
14
20
|
// =====================================================================
|
|
15
21
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
let _indexWriteLock = Promise.resolve();
|
|
22
|
+
const CONDUCTOR_HOME = join(getConfigDir(), '.conductor');
|
|
19
23
|
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
catch { return []; }
|
|
24
|
+
export function getConductorHome() {
|
|
25
|
+
return CONDUCTOR_HOME;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
async function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const data = JSON.stringify(index, null, 2);
|
|
29
|
-
const tmpPath = CONDUCTOR_INDEX_PATH + '.tmp';
|
|
30
|
-
await fs.writeFile(tmpPath, data);
|
|
31
|
-
await fs.rename(tmpPath, CONDUCTOR_INDEX_PATH);
|
|
32
|
-
};
|
|
33
|
-
_indexWriteLock = _indexWriteLock.then(doWrite, doWrite);
|
|
34
|
-
return _indexWriteLock;
|
|
28
|
+
export async function ensureConductorHome() {
|
|
29
|
+
await fs.mkdir(CONDUCTOR_HOME, { recursive: true });
|
|
30
|
+
return CONDUCTOR_HOME;
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
status: session.status,
|
|
41
|
-
name: session.name || '',
|
|
42
|
-
workDir: session.workDir || null,
|
|
43
|
-
userId: session.userId,
|
|
44
|
-
username: session.username,
|
|
45
|
-
agentId: session.agentId || null,
|
|
46
|
-
scenarioId: session.scenarioId || null,
|
|
47
|
-
createdAt: session.createdAt,
|
|
48
|
-
updatedAt: Date.now()
|
|
49
|
-
};
|
|
50
|
-
}
|
|
33
|
+
// =====================================================================
|
|
34
|
+
// Task Directory ({workDir}/.conductor/{taskId}/)
|
|
35
|
+
// =====================================================================
|
|
51
36
|
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
const entry = sessionToIndexEntry(session);
|
|
55
|
-
const idx = index.findIndex(e => e.sessionId === session.id);
|
|
56
|
-
if (idx >= 0) index[idx] = entry; else index.push(entry);
|
|
57
|
-
await saveConductorIndex(index);
|
|
37
|
+
export function getTaskDir(workDir, taskId) {
|
|
38
|
+
return join(workDir, '.conductor', taskId);
|
|
58
39
|
}
|
|
59
40
|
|
|
60
|
-
export async function
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
41
|
+
export async function initTaskDir(workDir, taskId) {
|
|
42
|
+
const dir = getTaskDir(workDir, taskId);
|
|
43
|
+
await fs.mkdir(join(dir, 'actors'), { recursive: true });
|
|
44
|
+
|
|
45
|
+
// Initialize empty files if they don't exist
|
|
46
|
+
const files = {
|
|
47
|
+
'CLAUDE.md': '',
|
|
48
|
+
'memory.md': '',
|
|
49
|
+
'status.json': JSON.stringify({ taskId, status: 'created', updatedAt: Date.now() }, null, 2)
|
|
50
|
+
};
|
|
51
|
+
for (const [name, content] of Object.entries(files)) {
|
|
52
|
+
const filePath = join(dir, name);
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(filePath);
|
|
55
|
+
} catch {
|
|
56
|
+
await fs.writeFile(filePath, content);
|
|
57
|
+
}
|
|
68
58
|
}
|
|
69
|
-
|
|
70
|
-
|
|
59
|
+
|
|
60
|
+
// Create worktree for this task (isolates file modifications per task)
|
|
61
|
+
let worktreePath = null;
|
|
62
|
+
try {
|
|
63
|
+
worktreePath = await createTaskWorktree(workDir, taskId, dir);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.warn(`[Conductor] Failed to create worktree for ${taskId}:`, e.message);
|
|
71
66
|
}
|
|
67
|
+
|
|
68
|
+
return { dir, worktreePath };
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
// =====================================================================
|
|
75
|
-
//
|
|
72
|
+
// State JSON (Global Task Registry)
|
|
76
73
|
// =====================================================================
|
|
77
74
|
|
|
75
|
+
let _stateWriteLock = Promise.resolve();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read state.json from disk (no locking).
|
|
79
|
+
* Internal helper — use inside _stateWriteLock or via loadState().
|
|
80
|
+
*/
|
|
81
|
+
async function loadStateRaw() {
|
|
82
|
+
const filePath = join(CONDUCTOR_HOME, 'state.json');
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
85
|
+
} catch {
|
|
86
|
+
return { tasks: {}, lastUpdate: 0 };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Write state to disk (no locking, atomic via tmp+rename).
|
|
92
|
+
* Internal helper — must only be called inside _stateWriteLock chain.
|
|
93
|
+
*/
|
|
94
|
+
async function writeState(state) {
|
|
95
|
+
await fs.mkdir(CONDUCTOR_HOME, { recursive: true });
|
|
96
|
+
state.lastUpdate = Date.now();
|
|
97
|
+
const data = JSON.stringify(state, null, 2);
|
|
98
|
+
const filePath = join(CONDUCTOR_HOME, 'state.json');
|
|
99
|
+
const tmpPath = filePath + '.tmp';
|
|
100
|
+
await fs.writeFile(tmpPath, data);
|
|
101
|
+
await fs.rename(tmpPath, filePath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load global task registry from state.json
|
|
106
|
+
* @returns {ConductorState} { tasks: Record<taskId, TaskRegistryEntry>, lastUpdate }
|
|
107
|
+
*/
|
|
108
|
+
export async function loadState() {
|
|
109
|
+
return loadStateRaw();
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
/**
|
|
79
|
-
*
|
|
80
|
-
* 注意:.conductor 放在 task 绑定的 workDir 下,不在 session 级
|
|
81
|
-
* session 级元数据存储在 ~/.claude/conductor/<sessionId>/
|
|
113
|
+
* Save global task registry to state.json (serialized via lock)
|
|
82
114
|
*/
|
|
83
|
-
|
|
115
|
+
export async function saveState(state) {
|
|
116
|
+
const doWrite = () => writeState(state);
|
|
117
|
+
_stateWriteLock = _stateWriteLock.then(doWrite, doWrite);
|
|
118
|
+
return _stateWriteLock;
|
|
119
|
+
}
|
|
84
120
|
|
|
85
|
-
|
|
86
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Update a single task entry in state.json
|
|
123
|
+
* Read-modify-write is serialized through _stateWriteLock to prevent races.
|
|
124
|
+
*/
|
|
125
|
+
export async function updateTaskInState(taskId, entry) {
|
|
126
|
+
const doUpdate = async () => {
|
|
127
|
+
const state = await loadStateRaw();
|
|
128
|
+
state.tasks[taskId] = { ...entry, lastUpdate: Date.now() };
|
|
129
|
+
await writeState(state);
|
|
130
|
+
};
|
|
131
|
+
_stateWriteLock = _stateWriteLock.then(doUpdate, doUpdate);
|
|
132
|
+
return _stateWriteLock;
|
|
87
133
|
}
|
|
88
134
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Remove a task entry from state.json
|
|
137
|
+
* Read-modify-write is serialized through _stateWriteLock to prevent races.
|
|
138
|
+
*/
|
|
139
|
+
export async function removeTaskFromState(taskId) {
|
|
140
|
+
const doRemove = async () => {
|
|
141
|
+
const state = await loadStateRaw();
|
|
142
|
+
delete state.tasks[taskId];
|
|
143
|
+
await writeState(state);
|
|
144
|
+
};
|
|
145
|
+
_stateWriteLock = _stateWriteLock.then(doRemove, doRemove);
|
|
146
|
+
return _stateWriteLock;
|
|
93
147
|
}
|
|
94
148
|
|
|
95
149
|
// =====================================================================
|
|
96
|
-
//
|
|
150
|
+
// Conductor Metadata (session.json — Claude session persistence)
|
|
97
151
|
// =====================================================================
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
export async function saveSessionMeta(session) {
|
|
102
|
-
const dir = getSessionDataDir(session.id);
|
|
103
|
-
await fs.mkdir(dir, { recursive: true });
|
|
153
|
+
export async function saveConductorMeta(conductor) {
|
|
154
|
+
const dir = await ensureConductorHome();
|
|
104
155
|
|
|
105
156
|
const meta = {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
title: t.title,
|
|
114
|
-
workDir: t.workDir,
|
|
115
|
-
status: t.status,
|
|
116
|
-
phase: t.phase,
|
|
117
|
-
progress: t.progress,
|
|
118
|
-
createdAt: t.createdAt,
|
|
119
|
-
updatedAt: t.updatedAt
|
|
120
|
-
})),
|
|
121
|
-
costUsd: session.costUsd,
|
|
122
|
-
totalInputTokens: session.totalInputTokens,
|
|
123
|
-
totalOutputTokens: session.totalOutputTokens,
|
|
124
|
-
userId: session.userId,
|
|
125
|
-
username: session.username,
|
|
126
|
-
agentId: session.agentId || null,
|
|
127
|
-
createdAt: session.createdAt,
|
|
157
|
+
status: conductor.status,
|
|
158
|
+
costUsd: conductor.costUsd,
|
|
159
|
+
totalInputTokens: conductor.totalInputTokens,
|
|
160
|
+
totalOutputTokens: conductor.totalOutputTokens,
|
|
161
|
+
userId: conductor.userId,
|
|
162
|
+
username: conductor.username,
|
|
163
|
+
createdAt: conductor.createdAt,
|
|
128
164
|
updatedAt: Date.now()
|
|
129
165
|
};
|
|
130
166
|
|
|
131
167
|
await fs.writeFile(join(dir, 'session.json'), JSON.stringify(meta, null, 2));
|
|
132
168
|
|
|
133
|
-
//
|
|
134
|
-
if (
|
|
135
|
-
const cleaned =
|
|
169
|
+
// Save UI messages (logrotate shards)
|
|
170
|
+
if (conductor.uiMessages && conductor.uiMessages.length > 0) {
|
|
171
|
+
const cleaned = conductor.uiMessages.map(m => {
|
|
136
172
|
const { _streaming, ...rest } = m;
|
|
137
173
|
return rest;
|
|
138
174
|
});
|
|
139
175
|
const json = JSON.stringify(cleaned);
|
|
140
|
-
if (json.length > MESSAGE_SHARD_SIZE && !
|
|
141
|
-
await rotateMessages(
|
|
176
|
+
if (json.length > MESSAGE_SHARD_SIZE && !conductor._rotating) {
|
|
177
|
+
await rotateMessages(conductor, dir, cleaned);
|
|
142
178
|
} else {
|
|
143
179
|
await fs.writeFile(join(dir, 'messages.json'), json);
|
|
144
180
|
}
|
|
145
181
|
}
|
|
146
182
|
}
|
|
147
183
|
|
|
148
|
-
async function
|
|
149
|
-
|
|
184
|
+
export async function loadConductorMeta() {
|
|
185
|
+
const filePath = join(CONDUCTOR_HOME, 'session.json');
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// =====================================================================
|
|
194
|
+
// UI Message Persistence (logrotate shards in conductor home)
|
|
195
|
+
// =====================================================================
|
|
196
|
+
|
|
197
|
+
const MESSAGE_SHARD_SIZE = 256 * 1024;
|
|
198
|
+
|
|
199
|
+
export async function loadConductorMessages() {
|
|
200
|
+
let messages = [];
|
|
201
|
+
try {
|
|
202
|
+
messages = JSON.parse(await fs.readFile(join(CONDUCTOR_HOME, 'messages.json'), 'utf-8'));
|
|
203
|
+
} catch { /* file may not exist */ }
|
|
204
|
+
let hasOlderMessages = false;
|
|
205
|
+
try {
|
|
206
|
+
await fs.access(join(CONDUCTOR_HOME, 'messages.1.json'));
|
|
207
|
+
hasOlderMessages = true;
|
|
208
|
+
} catch { /* no older shards */ }
|
|
209
|
+
return { messages, hasOlderMessages };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function rotateMessages(conductor, dir, cleaned) {
|
|
213
|
+
conductor._rotating = true;
|
|
150
214
|
try {
|
|
151
215
|
const halfLen = Math.floor(cleaned.length / 2);
|
|
152
216
|
let splitIdx = halfLen;
|
|
153
|
-
// 尝试找 system/route 类型消息作为断点
|
|
154
217
|
for (let i = halfLen; i > Math.max(0, halfLen - 20); i--) {
|
|
155
218
|
if (cleaned[i].type === 'system' || cleaned[i].type === 'task_created') {
|
|
156
219
|
splitIdx = i + 1;
|
|
@@ -171,11 +234,11 @@ async function rotateMessages(session, dir, cleaned) {
|
|
|
171
234
|
|
|
172
235
|
await fs.writeFile(join(dir, 'messages.1.json'), JSON.stringify(archivePart));
|
|
173
236
|
await fs.writeFile(join(dir, 'messages.json'), JSON.stringify(remainPart));
|
|
174
|
-
|
|
237
|
+
conductor.uiMessages = remainPart.map(m => ({ ...m }));
|
|
175
238
|
|
|
176
239
|
console.log(`[Conductor] Rotated: archived ${archivePart.length} msgs, kept ${remainPart.length}`);
|
|
177
240
|
} finally {
|
|
178
|
-
|
|
241
|
+
conductor._rotating = false;
|
|
179
242
|
}
|
|
180
243
|
}
|
|
181
244
|
|
|
@@ -205,47 +268,20 @@ export async function cleanupMessageShards(dir) {
|
|
|
205
268
|
} catch { /* dir may not exist */ }
|
|
206
269
|
}
|
|
207
270
|
|
|
208
|
-
export async function
|
|
209
|
-
const
|
|
210
|
-
try { return JSON.parse(await fs.readFile(join(dir, 'session.json'), 'utf-8')); }
|
|
211
|
-
catch { return null; }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export async function loadSessionMessages(sessionId) {
|
|
215
|
-
const dir = getSessionDataDir(sessionId);
|
|
216
|
-
let messages = [];
|
|
217
|
-
try { messages = JSON.parse(await fs.readFile(join(dir, 'messages.json'), 'utf-8')); }
|
|
218
|
-
catch { /* file may not exist */ }
|
|
219
|
-
let hasOlderMessages = false;
|
|
220
|
-
try {
|
|
221
|
-
await fs.access(join(dir, 'messages.1.json'));
|
|
222
|
-
hasOlderMessages = true;
|
|
223
|
-
} catch { /* no older shards */ }
|
|
224
|
-
return { messages, hasOlderMessages };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export async function handleLoadConductorHistory(msg, conductorSessions, sendConductorMessage) {
|
|
228
|
-
const { sessionId, requestId } = msg;
|
|
271
|
+
export async function handleLoadConductorHistory(msg, conductor, sendMsg) {
|
|
272
|
+
const { requestId } = msg;
|
|
229
273
|
const shardIndex = parseInt(msg.shardIndex, 10);
|
|
230
274
|
|
|
231
|
-
if (!Number.isFinite(shardIndex) || shardIndex < 1) {
|
|
232
|
-
|
|
233
|
-
type: 'conductor_history_loaded',
|
|
234
|
-
sessionId, shardIndex: msg.shardIndex, requestId,
|
|
235
|
-
messages: [], hasMore: false
|
|
236
|
-
});
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
if (!conductorSessions.has(sessionId)) {
|
|
240
|
-
sendConductorMessage({
|
|
275
|
+
if (!Number.isFinite(shardIndex) || shardIndex < 1 || !conductor) {
|
|
276
|
+
sendMsg({
|
|
241
277
|
type: 'conductor_history_loaded',
|
|
242
|
-
|
|
278
|
+
shardIndex: msg.shardIndex, requestId,
|
|
243
279
|
messages: [], hasMore: false
|
|
244
280
|
});
|
|
245
281
|
return;
|
|
246
282
|
}
|
|
247
283
|
|
|
248
|
-
const dir =
|
|
284
|
+
const dir = getConductorHome();
|
|
249
285
|
const shardPath = join(dir, `messages.${shardIndex}.json`);
|
|
250
286
|
let messages = [];
|
|
251
287
|
try {
|
|
@@ -253,9 +289,9 @@ export async function handleLoadConductorHistory(msg, conductorSessions, sendCon
|
|
|
253
289
|
} catch { /* shard doesn't exist */ }
|
|
254
290
|
|
|
255
291
|
const hasMore = shardIndex < await getMaxShardIndex(dir);
|
|
256
|
-
|
|
292
|
+
sendMsg({
|
|
257
293
|
type: 'conductor_history_loaded',
|
|
258
|
-
|
|
294
|
+
shardIndex, requestId,
|
|
259
295
|
messages, hasMore
|
|
260
296
|
});
|
|
261
297
|
}
|