@yeaft/webchat-agent 0.1.509 → 0.1.511
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/connection/message-router.js +11 -0
- package/crew/builtin-actions.js +154 -0
- package/crew/role-output.js +79 -13
- package/crew/role-states.js +180 -0
- package/crew/routing-fallback.js +64 -0
- package/crew/routing-metrics.js +215 -0
- package/crew/routing.js +381 -55
- package/package.json +1 -1
- package/unify/web-bridge.js +9 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* message-router.js — Server-bound message dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* task-330c lint guard:
|
|
5
|
+
* ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
|
|
6
|
+
* style strips on routed message payloads. Crew ROUTE stripping is
|
|
7
|
+
* owned EXCLUSIVELY by `agent/crew/routing.js` `parseRoutes()` which
|
|
8
|
+
* returns `{routes, displayBody}` with exact ranges removed. A second
|
|
9
|
+
* strip here would re-process already-cleaned text and risks both
|
|
10
|
+
* double-strip artefacts and the trailing-prose bug fixed by task-328.
|
|
11
|
+
*/
|
|
1
12
|
import ctx from '../context.js';
|
|
2
13
|
import { decodeKey } from '../encryption.js';
|
|
3
14
|
import { handleTerminalCreate, handleTerminalInput, handleTerminalResize, handleTerminalClose } from '../terminal.js';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew — built-in role actions (task-330a §B)
|
|
3
|
+
*
|
|
4
|
+
* These two actions replace the "PM 给自己发闭环消息" anti-pattern that
|
|
5
|
+
* task-330a §A now rejects at the routing layer:
|
|
6
|
+
*
|
|
7
|
+
* • taskClose(session, { taskId, summary, fromRole })
|
|
8
|
+
* — directly mark the task complete on the kanban + broadcast a
|
|
9
|
+
* status card via sendCrewMessage. No ROUTE round-trip.
|
|
10
|
+
*
|
|
11
|
+
* • roleStandby(session, { role, reason, fromRole })
|
|
12
|
+
* — flip a role's runtime state to 'standby' and broadcast.
|
|
13
|
+
* Persistence to .crew/context/role-states.json is a 330d
|
|
14
|
+
* concern — we only define the in-memory contract here.
|
|
15
|
+
*
|
|
16
|
+
* Both functions are pure server-side helpers. They do NOT consume a
|
|
17
|
+
* routing turn (no session.round++, no dispatchToRole). Callers that
|
|
18
|
+
* choose to expose them as Claude tools (via canCallTool / mcp) must
|
|
19
|
+
* wire that separately — task-330a defines the contract; tool-binding
|
|
20
|
+
* lands in 330d alongside the role-state persistence layer.
|
|
21
|
+
*
|
|
22
|
+
* Red lines (per PM dispatch):
|
|
23
|
+
* - Do not mutate routing protocol shape (.routes / displayBody).
|
|
24
|
+
* - Do not break task-319/328 (parser tests stay green).
|
|
25
|
+
* - Old session replay must keep working — we only add new keys to
|
|
26
|
+
* session.roleStates[role], never remove.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { sendCrewMessage, sendStatusUpdate } from './ui-messages.js';
|
|
30
|
+
import { updateKanban, appendChangelog, updateFeatureIndex, isValidTaskId } from './task-files.js';
|
|
31
|
+
|
|
32
|
+
/** Valid standby reasons — extend with care; consumers may grep for these. */
|
|
33
|
+
export const STANDBY_REASONS = Object.freeze([
|
|
34
|
+
'task_closed', 'awaiting_input', 'manual', 'idle', 'paused',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mark a task as complete on the kanban + broadcast a status card.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors the side-effects that role-output.js performs when it detects a
|
|
41
|
+
* completed TASKS block, so the two paths converge on the same kanban
|
|
42
|
+
* state regardless of which one fires.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} session
|
|
45
|
+
* @param {{ taskId: string, summary?: string, fromRole?: string }} params
|
|
46
|
+
* @returns {Promise<{ ok: boolean, taskId: string, reason?: string }>}
|
|
47
|
+
*/
|
|
48
|
+
export async function taskClose(session, { taskId, summary, fromRole }) {
|
|
49
|
+
if (!session) {
|
|
50
|
+
return { ok: false, taskId: taskId || null, reason: 'no_session' };
|
|
51
|
+
}
|
|
52
|
+
if (!taskId || !isValidTaskId(taskId)) {
|
|
53
|
+
return { ok: false, taskId: taskId || null, reason: 'invalid_task_id' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Mark in the in-memory completion set so future kanban rebuilds keep it.
|
|
57
|
+
if (!session._completedTaskIds) session._completedTaskIds = new Set();
|
|
58
|
+
const wasAlreadyCompleted = session._completedTaskIds.has(taskId);
|
|
59
|
+
session._completedTaskIds.add(taskId);
|
|
60
|
+
|
|
61
|
+
const feature = session.features?.get(taskId);
|
|
62
|
+
const taskTitle = feature?.taskTitle || taskId;
|
|
63
|
+
const cleanSummary = (summary && String(summary).trim()) || '已完成';
|
|
64
|
+
|
|
65
|
+
// Persist to the kanban file (best-effort; warns on failure).
|
|
66
|
+
try {
|
|
67
|
+
await updateKanban(session, { taskId, completed: true, summary: cleanSummary });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn(`[Crew] taskClose: updateKanban failed for ${taskId}:`, e.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Append to features index + changelog only on first close (idempotent).
|
|
73
|
+
if (!wasAlreadyCompleted) {
|
|
74
|
+
updateFeatureIndex(session)
|
|
75
|
+
.catch(e => console.warn('[Crew] taskClose: updateFeatureIndex failed:', e.message));
|
|
76
|
+
appendChangelog(session, taskId, taskTitle)
|
|
77
|
+
.catch(e => console.warn(`[Crew] taskClose: appendChangelog failed for ${taskId}:`, e.message));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Broadcast status card so the UI reflects the close immediately.
|
|
81
|
+
try {
|
|
82
|
+
sendCrewMessage({
|
|
83
|
+
type: 'crew_task_closed',
|
|
84
|
+
sessionId: session.id,
|
|
85
|
+
taskId,
|
|
86
|
+
taskTitle,
|
|
87
|
+
summary: cleanSummary,
|
|
88
|
+
fromRole: fromRole || null,
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
sendStatusUpdate(session);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn(`[Crew] taskClose: broadcast failed for ${taskId}:`, e.message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ok: true, taskId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Flip a role into 'standby' (in-memory) and broadcast.
|
|
101
|
+
*
|
|
102
|
+
* 330a defines the contract; 330d wires the .crew/context/role-states.json
|
|
103
|
+
* persistence. We DO mutate `session.roleStates[role].standby` here so the
|
|
104
|
+
* UI / status pipeline can reflect the change immediately, and 330d can
|
|
105
|
+
* snapshot it on the next debounced save.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} session
|
|
108
|
+
* @param {{ role: string, reason?: string, fromRole?: string }} params
|
|
109
|
+
* @returns {{ ok: boolean, role: string, reason?: string }}
|
|
110
|
+
*/
|
|
111
|
+
export function roleStandby(session, { role, reason, fromRole }) {
|
|
112
|
+
if (!session) {
|
|
113
|
+
return { ok: false, role: role || null, reason: 'no_session' };
|
|
114
|
+
}
|
|
115
|
+
if (!role || typeof role !== 'string') {
|
|
116
|
+
return { ok: false, role: role || null, reason: 'invalid_role' };
|
|
117
|
+
}
|
|
118
|
+
if (!session.roles || !session.roles.has(role)) {
|
|
119
|
+
return { ok: false, role, reason: 'unknown_role' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const normalizedReason = reason && STANDBY_REASONS.includes(reason)
|
|
123
|
+
? reason
|
|
124
|
+
: 'manual';
|
|
125
|
+
|
|
126
|
+
// Ensure the roleState bucket exists (cold-start safe).
|
|
127
|
+
let roleState = session.roleStates?.get?.(role);
|
|
128
|
+
if (!roleState) {
|
|
129
|
+
roleState = {};
|
|
130
|
+
session.roleStates?.set?.(role, roleState);
|
|
131
|
+
}
|
|
132
|
+
// New keys only — never delete legacy fields (replay safety).
|
|
133
|
+
roleState.standby = {
|
|
134
|
+
reason: normalizedReason,
|
|
135
|
+
since: Date.now(),
|
|
136
|
+
setBy: fromRole || null,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
sendCrewMessage({
|
|
141
|
+
type: 'crew_role_standby',
|
|
142
|
+
sessionId: session.id,
|
|
143
|
+
role,
|
|
144
|
+
reason: normalizedReason,
|
|
145
|
+
fromRole: fromRole || null,
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
});
|
|
148
|
+
sendStatusUpdate(session);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.warn(`[Crew] roleStandby: broadcast failed for ${role}:`, e.message);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { ok: true, role, reason: normalizedReason };
|
|
154
|
+
}
|
package/crew/role-output.js
CHANGED
|
@@ -7,6 +7,8 @@ import { saveRoleSessionId, clearRoleSessionId, classifyRoleError, createRoleQue
|
|
|
7
7
|
import { parseRoutes, executeRoute, dispatchToRole } from './routing.js';
|
|
8
8
|
import { parseCompletedTasks, updateFeatureIndex, appendChangelog, saveRoleWorkSummary, updateKanban } from './task-files.js';
|
|
9
9
|
import { debouncedSaveSessionMeta, saveSessionMeta } from './persistence.js';
|
|
10
|
+
import { recordRoutingEvent } from './routing-metrics.js';
|
|
11
|
+
import { resolveFallbackTarget } from './routing-fallback.js';
|
|
10
12
|
import ctx from '../context.js';
|
|
11
13
|
|
|
12
14
|
// Context 使用率常量(运行时从 ctx.CONFIG 读取)
|
|
@@ -153,12 +155,36 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
153
155
|
});
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
// 解析路由
|
|
157
|
-
|
|
158
|
-
//
|
|
158
|
+
// 解析路由 — task-328: parseRoutes returns a decorated Array
|
|
159
|
+
// (`.routes`/`.displayBody`/`.strippedRanges`). We keep treating it as
|
|
160
|
+
// an Array for routing iteration, but use `.displayBody` whenever we
|
|
161
|
+
// need the role's prose with ROUTE blocks accurately removed.
|
|
162
|
+
const parseResult = parseRoutes(roleState.accumulatedText);
|
|
163
|
+
const routes = parseResult;
|
|
164
|
+
const displayBody = parseResult.displayBody || roleState.accumulatedText;
|
|
165
|
+
// task-330b §B item 1: detect "parse-fail" — text mentions a ROUTE
|
|
166
|
+
// opener but nothing parseable came out. Counts as a separate metric
|
|
167
|
+
// from plain missing-route so dashboards can split malformed-block
|
|
168
|
+
// (likely template/prompt drift) from forgot-to-write-block.
|
|
169
|
+
if (routes.length === 0 && /---\s*ROUTE\s*---/i.test(roleState.accumulatedText || '')) {
|
|
170
|
+
recordRoutingEvent(session, 'parse-fail', {
|
|
171
|
+
fromRole: roleName,
|
|
172
|
+
taskId: roleState.currentTask?.taskId || null,
|
|
173
|
+
note: 'ROUTE opener present but no parseable block',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// Fallback: 如果 route summary 仍为空占位符,用 displayBody 末尾 500 字符
|
|
177
|
+
// task-330c: source switched from `accumulatedText.slice(-500)` to
|
|
178
|
+
// `displayBody.slice(-500)` — accumulatedText still contains
|
|
179
|
+
// ---ROUTE--- markers; using it would re-strip already-stripped text
|
|
180
|
+
// and risk truncating mid-marker. displayBody is the parser's
|
|
181
|
+
// post-strip prose and is the single source of truth for "what the
|
|
182
|
+
// role said outside its routing blocks".
|
|
183
|
+
// ⚠️ DO NOT add any further `.replace(/---ROUTE---.../g, '')` on
|
|
184
|
+
// `displayBody` here or downstream — it is already strip-clean.
|
|
159
185
|
for (const route of routes) {
|
|
160
|
-
if (route.summary === '[该角色未提供消息摘要]' &&
|
|
161
|
-
const tail =
|
|
186
|
+
if (route.summary === '[该角色未提供消息摘要]' && displayBody) {
|
|
187
|
+
const tail = displayBody.slice(-500).trim();
|
|
162
188
|
if (tail) route.summary = `[auto-extracted]\n${tail}`;
|
|
163
189
|
}
|
|
164
190
|
}
|
|
@@ -191,7 +217,12 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
191
217
|
}
|
|
192
218
|
|
|
193
219
|
// 保存本 turn 文本(供 routing.js 预检时 saveRoleWorkSummary 使用)
|
|
220
|
+
// task-328: lastTurnText keeps the raw transcript (consumers may need
|
|
221
|
+
// ROUTE blocks for analysis); lastTurnDisplayBody is the parser-clean
|
|
222
|
+
// body used by auto-forward/UI logic to avoid sending broken ROUTE
|
|
223
|
+
// residue downstream.
|
|
194
224
|
roleState.lastTurnText = roleState.accumulatedText;
|
|
225
|
+
roleState.lastTurnDisplayBody = displayBody;
|
|
195
226
|
roleState.accumulatedText = '';
|
|
196
227
|
roleState.turnActive = false;
|
|
197
228
|
|
|
@@ -255,24 +286,59 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
255
286
|
});
|
|
256
287
|
sendStatusUpdate(session);
|
|
257
288
|
} else {
|
|
258
|
-
// ★ No ROUTE found —
|
|
259
|
-
|
|
289
|
+
// ★ No ROUTE found — resolve via the single fallback policy
|
|
290
|
+
// (task-330b §B item 3). The resolver enforces:
|
|
291
|
+
// - PM never auto-forwards to itself (parks pending instead)
|
|
292
|
+
// - non-PM with active task / routing intent → PM (auto-forward)
|
|
293
|
+
// - everyone else → null (process human queue)
|
|
260
294
|
const hasActiveTask = !!roleState.currentTask;
|
|
261
295
|
const hasRouteIntent = _detectRouteIntent(roleState.lastTurnText);
|
|
296
|
+
const fallbackTo = resolveFallbackTarget(session, roleName, 'missing-route', {
|
|
297
|
+
hasActiveTask,
|
|
298
|
+
hasRouteIntent,
|
|
299
|
+
});
|
|
262
300
|
|
|
263
|
-
if (
|
|
264
|
-
//
|
|
265
|
-
// auto-forward to PM so the message doesn't get lost
|
|
301
|
+
if (fallbackTo) {
|
|
302
|
+
// §B item 1 — record the metric on every fallback dispatch.
|
|
266
303
|
const reason = hasActiveTask ? 'has active task' : 'has routing intent';
|
|
267
|
-
|
|
268
|
-
|
|
304
|
+
recordRoutingEvent(session, 'fallback-forward', {
|
|
305
|
+
fromRole: roleName,
|
|
306
|
+
toRole: fallbackTo,
|
|
307
|
+
taskId: roleState.currentTask?.taskId || null,
|
|
308
|
+
note: reason,
|
|
309
|
+
});
|
|
310
|
+
// Also record the upstream cause so dashboards can split
|
|
311
|
+
// "missing ROUTE" events from the auto-forward dispatches.
|
|
312
|
+
recordRoutingEvent(session, 'missing-route', {
|
|
313
|
+
fromRole: roleName,
|
|
314
|
+
taskId: roleState.currentTask?.taskId || null,
|
|
315
|
+
note: reason,
|
|
316
|
+
});
|
|
317
|
+
// task-328: forward parser-clean displayBody (ROUTE residue removed)
|
|
318
|
+
// so PM sees the actual prose, not stray END markers.
|
|
319
|
+
console.log(`[Crew] ${roleName} turn ended without ROUTE (${reason}) — auto-forwarding to ${fallbackTo}`);
|
|
320
|
+
const forwardSource = roleState.lastTurnDisplayBody || roleState.lastTurnText || '';
|
|
321
|
+
const autoSummary = `[auto-forward: ${roleName} turn 结束但未输出 ROUTE 块 (${reason})]\n${forwardSource.slice(-800).trim()}`;
|
|
269
322
|
await executeRoute(session, roleName, {
|
|
270
|
-
to:
|
|
323
|
+
to: fallbackTo,
|
|
271
324
|
summary: autoSummary,
|
|
272
325
|
taskId: roleState.currentTask?.taskId || null,
|
|
273
326
|
taskTitle: roleState.currentTask?.taskTitle || null,
|
|
274
327
|
});
|
|
275
328
|
} else {
|
|
329
|
+
// §B item 2 — PM no-auto-forward: when PM ends a turn without
|
|
330
|
+
// ROUTE we DO NOT self-route. Instead we record the metric and
|
|
331
|
+
// park the session (effectively pending) so the user's next
|
|
332
|
+
// input drives the next step. Same applies to non-PM roles
|
|
333
|
+
// with no active task / no routing intent.
|
|
334
|
+
if (roleName === session.decisionMaker && (hasActiveTask || hasRouteIntent)) {
|
|
335
|
+
recordRoutingEvent(session, 'missing-route', {
|
|
336
|
+
fromRole: roleName,
|
|
337
|
+
taskId: roleState.currentTask?.taskId || null,
|
|
338
|
+
note: 'pm-pending (no auto-forward)',
|
|
339
|
+
});
|
|
340
|
+
console.log(`[Crew] ${roleName} (PM) turn ended without ROUTE — staying pending (no self-forward)`);
|
|
341
|
+
}
|
|
276
342
|
const { processHumanQueue } = await import('./human-interaction.js');
|
|
277
343
|
await processHumanQueue(session);
|
|
278
344
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew — Role State Store (task-330d, Fowler Final Spec §D)
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy "standby task" pattern (where PM idle was modelled
|
|
5
|
+
* as a synthetic task in `.crew/context/features/standby.md`). Role idle/
|
|
6
|
+
* busy/pending state is now first-class and persisted to a single file:
|
|
7
|
+
*
|
|
8
|
+
* <sharedDir>/context/role-states.json
|
|
9
|
+
*
|
|
10
|
+
* File shape:
|
|
11
|
+
* {
|
|
12
|
+
* "version": 1,
|
|
13
|
+
* "states": {
|
|
14
|
+
* "<roleName>": { role, status, since, reason }
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Status values:
|
|
19
|
+
* - 'standby' : role has no active task and is waiting for routing
|
|
20
|
+
* - 'busy' : role is currently executing a turn
|
|
21
|
+
* - 'pending' : role finished a turn but did not emit a ROUTE
|
|
22
|
+
*
|
|
23
|
+
* Public API (consumed by the `role_standby` tool added in task-330a):
|
|
24
|
+
* - getRoleState(sharedDir, role) → state | null
|
|
25
|
+
* - setRoleState(sharedDir, role, patch) → state
|
|
26
|
+
* - listRoleStates(sharedDir) → Record<role, state>
|
|
27
|
+
*
|
|
28
|
+
* Atomicity: writes go to `role-states.json.tmp` then rename, matching the
|
|
29
|
+
* convention in persistence.js. A per-process write lock (Promise chain)
|
|
30
|
+
* serialises concurrent setRoleState calls so the file never tears.
|
|
31
|
+
*
|
|
32
|
+
* Backward compatibility: a session that was created before role-states.json
|
|
33
|
+
* existed simply has no file; getRoleState returns null and setRoleState
|
|
34
|
+
* creates the file lazily. No migration step is required.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { promises as fs } from 'fs';
|
|
38
|
+
import { join } from 'path';
|
|
39
|
+
|
|
40
|
+
const FILE_NAME = 'role-states.json';
|
|
41
|
+
const VERSION = 1;
|
|
42
|
+
const VALID_STATUSES = new Set(['standby', 'busy', 'pending']);
|
|
43
|
+
|
|
44
|
+
// Per-sharedDir write lock. Keyed by absolute path so multiple sessions in
|
|
45
|
+
// one process don't serialise against each other.
|
|
46
|
+
const _writeLocks = new Map();
|
|
47
|
+
|
|
48
|
+
function _lockKey(sharedDir) {
|
|
49
|
+
return sharedDir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _filePath(sharedDir) {
|
|
53
|
+
return join(sharedDir, 'context', FILE_NAME);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function _readAll(sharedDir) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = await fs.readFile(_filePath(sharedDir), 'utf-8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.states || typeof parsed.states !== 'object') {
|
|
61
|
+
return { version: VERSION, states: {} };
|
|
62
|
+
}
|
|
63
|
+
return { version: parsed.version || VERSION, states: parsed.states };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err && err.code === 'ENOENT') return { version: VERSION, states: {} };
|
|
66
|
+
// Corrupt JSON → start fresh rather than crash. This matches the
|
|
67
|
+
// "legacy session replay compatibility" red line: a broken file must
|
|
68
|
+
// not block resume.
|
|
69
|
+
return { version: VERSION, states: {} };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function _writeAtomic(sharedDir, payload) {
|
|
74
|
+
const dir = join(sharedDir, 'context');
|
|
75
|
+
await fs.mkdir(dir, { recursive: true });
|
|
76
|
+
const target = _filePath(sharedDir);
|
|
77
|
+
const tmp = target + '.tmp';
|
|
78
|
+
await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
|
|
79
|
+
await fs.rename(tmp, target);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read the current state for a single role.
|
|
84
|
+
* @param {string} sharedDir — session shared directory (`.crew` root)
|
|
85
|
+
* @param {string} role — role name
|
|
86
|
+
* @returns {Promise<null | {role:string, status:string, since:number, reason?:string}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function getRoleState(sharedDir, role) {
|
|
89
|
+
if (!sharedDir || !role) return null;
|
|
90
|
+
const all = await _readAll(sharedDir);
|
|
91
|
+
return all.states[role] || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read every role's state at once. Returns a plain object keyed by role
|
|
96
|
+
* name (empty when the file is missing).
|
|
97
|
+
*/
|
|
98
|
+
export async function listRoleStates(sharedDir) {
|
|
99
|
+
if (!sharedDir) return {};
|
|
100
|
+
const all = await _readAll(sharedDir);
|
|
101
|
+
return { ...all.states };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Patch a single role's state. Unspecified fields are preserved from the
|
|
106
|
+
* existing record. `since` defaults to Date.now() when status changes (or
|
|
107
|
+
* when no prior record exists). Throws on invalid status — callers are
|
|
108
|
+
* expected to pass one of standby|busy|pending.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} sharedDir
|
|
111
|
+
* @param {string} role
|
|
112
|
+
* @param {Partial<{status:string, reason:string, since:number}>} patch
|
|
113
|
+
* @returns {Promise<{role:string, status:string, since:number, reason?:string}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function setRoleState(sharedDir, role, patch = {}) {
|
|
116
|
+
if (!sharedDir) throw new Error('setRoleState: sharedDir required');
|
|
117
|
+
if (!role) throw new Error('setRoleState: role required');
|
|
118
|
+
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) {
|
|
119
|
+
throw new Error(`setRoleState: invalid status '${patch.status}'`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const key = _lockKey(sharedDir);
|
|
123
|
+
const prev = _writeLocks.get(key) || Promise.resolve();
|
|
124
|
+
|
|
125
|
+
const next = prev.then(async () => {
|
|
126
|
+
const all = await _readAll(sharedDir);
|
|
127
|
+
const existing = all.states[role] || null;
|
|
128
|
+
const statusChanged = patch.status !== undefined && (!existing || existing.status !== patch.status);
|
|
129
|
+
const merged = {
|
|
130
|
+
role,
|
|
131
|
+
status: patch.status !== undefined ? patch.status : (existing ? existing.status : 'standby'),
|
|
132
|
+
since: patch.since !== undefined
|
|
133
|
+
? patch.since
|
|
134
|
+
: (statusChanged || !existing ? Date.now() : existing.since),
|
|
135
|
+
};
|
|
136
|
+
if (patch.reason !== undefined) {
|
|
137
|
+
merged.reason = patch.reason;
|
|
138
|
+
} else if (existing && existing.reason !== undefined && !statusChanged) {
|
|
139
|
+
merged.reason = existing.reason;
|
|
140
|
+
}
|
|
141
|
+
all.states[role] = merged;
|
|
142
|
+
all.version = VERSION;
|
|
143
|
+
await _writeAtomic(sharedDir, all);
|
|
144
|
+
return merged;
|
|
145
|
+
}, async () => {
|
|
146
|
+
// If a prior write rejected we still want to attempt this one rather
|
|
147
|
+
// than poisoning the chain forever.
|
|
148
|
+
const all = await _readAll(sharedDir);
|
|
149
|
+
const merged = {
|
|
150
|
+
role,
|
|
151
|
+
status: patch.status || 'standby',
|
|
152
|
+
since: patch.since !== undefined ? patch.since : Date.now(),
|
|
153
|
+
...(patch.reason !== undefined ? { reason: patch.reason } : {}),
|
|
154
|
+
};
|
|
155
|
+
all.states[role] = merged;
|
|
156
|
+
await _writeAtomic(sharedDir, all);
|
|
157
|
+
return merged;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Hold onto the chain so the next caller waits on this write.
|
|
161
|
+
_writeLocks.set(key, next.catch(() => {}));
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Test/diagnostic helper — clear in-process write lock for a sharedDir.
|
|
167
|
+
* Production callers should never need this; tests use it between runs to
|
|
168
|
+
* avoid bleeding lock state across describe blocks.
|
|
169
|
+
*/
|
|
170
|
+
export function __resetWriteLockForTests(sharedDir) {
|
|
171
|
+
if (sharedDir) _writeLocks.delete(sharedDir);
|
|
172
|
+
else _writeLocks.clear();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Constants exported for tests + downstream consumers (the `role_standby`
|
|
177
|
+
* tool in task-330a).
|
|
178
|
+
*/
|
|
179
|
+
export const ROLE_STATE_FILE_NAME = FILE_NAME;
|
|
180
|
+
export const ROLE_STATE_STATUSES = ['standby', 'busy', 'pending'];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-330b — Single fallback-target resolver (Final Spec §B item 3).
|
|
3
|
+
*
|
|
4
|
+
* Replaces ad-hoc `session.decisionMaker` lookups scattered across
|
|
5
|
+
* routing.js / role-output.js / human-interaction.js when a message has
|
|
6
|
+
* nowhere obvious to go. Centralising this in ONE function lets us:
|
|
7
|
+
*
|
|
8
|
+
* - test the fallback policy in isolation
|
|
9
|
+
* - add per-reason policy without touching every call site
|
|
10
|
+
* - keep the §B `recordRoutingEvent` calls right next to the decision
|
|
11
|
+
*
|
|
12
|
+
* Policy (mirrors what the codebase already does — this is a refactor,
|
|
13
|
+
* not a behaviour change for the existing 4 reasons):
|
|
14
|
+
*
|
|
15
|
+
* missing-route → PM (decisionMaker) IF caller is non-PM AND has
|
|
16
|
+
* active task or routing intent. Else: pending (null).
|
|
17
|
+
* PM-no-auto-forward rule (§B item 2): if caller IS
|
|
18
|
+
* PM → ALWAYS pending (null), even with active task.
|
|
19
|
+
* parse-fail → PM (decisionMaker)
|
|
20
|
+
* self-route → null (rejected; §A handles, §B only logs)
|
|
21
|
+
* state-stopped → null (let session.status block the dispatch)
|
|
22
|
+
* fallback-forward → PM (decisionMaker) — explicit auto-forward path
|
|
23
|
+
*
|
|
24
|
+
* Returns the target role NAME, or null when "do nothing / pending".
|
|
25
|
+
*
|
|
26
|
+
* @param {object} session — crew session (.decisionMaker, .roles)
|
|
27
|
+
* @param {string} fromRole
|
|
28
|
+
* @param {string} reason — one of ROUTING_REASONS
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {boolean} [opts.hasActiveTask]
|
|
31
|
+
* @param {boolean} [opts.hasRouteIntent]
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
export function resolveFallbackTarget(session, fromRole, reason, opts = {}) {
|
|
35
|
+
if (!session) return null;
|
|
36
|
+
const dm = session.decisionMaker || null;
|
|
37
|
+
|
|
38
|
+
switch (reason) {
|
|
39
|
+
case 'missing-route': {
|
|
40
|
+
// PM-no-auto-forward rule (§B item 2): PM never auto-forwards to
|
|
41
|
+
// itself. Returning null signals "park as pending, wait for human".
|
|
42
|
+
if (fromRole === dm) return null;
|
|
43
|
+
// Non-PM: only auto-forward when there is something to forward
|
|
44
|
+
// (active task OR detected routing intent in the prose).
|
|
45
|
+
if (opts.hasActiveTask || opts.hasRouteIntent) return dm;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
case 'parse-fail':
|
|
49
|
+
// Parser found a ROUTE block but couldn't read it — PM should see
|
|
50
|
+
// the malformed output to decide next step.
|
|
51
|
+
return dm;
|
|
52
|
+
case 'self-route':
|
|
53
|
+
// §A rejects self-routing; §B records, no fallback dispatch.
|
|
54
|
+
return null;
|
|
55
|
+
case 'state-stopped':
|
|
56
|
+
// Session is stopped/paused; routing must not auto-resume here.
|
|
57
|
+
return null;
|
|
58
|
+
case 'fallback-forward':
|
|
59
|
+
// Explicit safety net (the rename of the existing auto-forward).
|
|
60
|
+
return dm;
|
|
61
|
+
default:
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-330b — Routing metrics counter (Final Spec §B).
|
|
3
|
+
*
|
|
4
|
+
* Centralised observability for crew routing fallbacks. Five canonical
|
|
5
|
+
* reasons that any fallback path MUST pass to `recordRoutingEvent`:
|
|
6
|
+
*
|
|
7
|
+
* - missing-route : turn ended with no parseable ROUTE block
|
|
8
|
+
* - parse-fail : ROUTE block found but parse returned null/invalid
|
|
9
|
+
* - self-route : route.to resolves to the sender (rejected by §A)
|
|
10
|
+
* - state-stopped : message arrived while session was stopped/paused
|
|
11
|
+
* and was diverted/dropped
|
|
12
|
+
* - fallback-forward : auto-forward path engaged (non-PM → PM safety net)
|
|
13
|
+
*
|
|
14
|
+
* Counters are kept in-memory keyed by `${sessionId}::${reason}` and flushed
|
|
15
|
+
* to `${sharedDir}/context/routing-metrics.json` periodically (default 30s)
|
|
16
|
+
* AND on demand via `flushRoutingMetricsNow(session)`. The on-disk format:
|
|
17
|
+
*
|
|
18
|
+
* {
|
|
19
|
+
* "schemaVersion": 1,
|
|
20
|
+
* "lastFlushedAt": <ms>,
|
|
21
|
+
* "counts": {
|
|
22
|
+
* "missing-route": 4,
|
|
23
|
+
* "parse-fail": 1,
|
|
24
|
+
* "self-route": 0,
|
|
25
|
+
* "state-stopped": 2,
|
|
26
|
+
* "fallback-forward": 4
|
|
27
|
+
* },
|
|
28
|
+
* "recent": [
|
|
29
|
+
* { ts, reason, fromRole, toRole?, taskId?, note? },
|
|
30
|
+
* ... // bounded ring buffer (50)
|
|
31
|
+
* ]
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Red lines (§330b):
|
|
35
|
+
* - Pure observer; never mutates routing decisions.
|
|
36
|
+
* - Never throws; failures degrade to console.warn so callers can rely on
|
|
37
|
+
* `recordRoutingEvent()` being safe inside hot paths.
|
|
38
|
+
*
|
|
39
|
+
* Red lines (§330a — shared with this PR):
|
|
40
|
+
* - No engine state-machine touch.
|
|
41
|
+
* - PM-self-loop is the responsibility of §A; §B only records the metric
|
|
42
|
+
* when §A rejects.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { promises as fs } from 'fs';
|
|
46
|
+
import { join } from 'path';
|
|
47
|
+
|
|
48
|
+
export const ROUTING_REASONS = Object.freeze([
|
|
49
|
+
'missing-route',
|
|
50
|
+
'parse-fail',
|
|
51
|
+
'self-route',
|
|
52
|
+
'state-stopped',
|
|
53
|
+
'fallback-forward',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const REASON_SET = new Set(ROUTING_REASONS);
|
|
57
|
+
const RECENT_RING_SIZE = 50;
|
|
58
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* In-process state — ONE bag per process. Keyed by sessionId so multiple
|
|
62
|
+
* crew sessions running in the same agent each keep their own counts.
|
|
63
|
+
*
|
|
64
|
+
* Shape: Map<sessionId, {
|
|
65
|
+
* sharedDir: string,
|
|
66
|
+
* counts: Record<reason, number>,
|
|
67
|
+
* recent: Array<{ ts, reason, fromRole, toRole?, taskId?, note? }>,
|
|
68
|
+
* dirty: boolean,
|
|
69
|
+
* flushTimer: NodeJS.Timeout | null,
|
|
70
|
+
* }>
|
|
71
|
+
*/
|
|
72
|
+
const _state = new Map();
|
|
73
|
+
|
|
74
|
+
function _zeroCounts() {
|
|
75
|
+
const c = {};
|
|
76
|
+
for (const r of ROUTING_REASONS) c[r] = 0;
|
|
77
|
+
return c;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _getOrInit(session) {
|
|
81
|
+
const sid = session?.id;
|
|
82
|
+
if (!sid) return null;
|
|
83
|
+
let bag = _state.get(sid);
|
|
84
|
+
if (!bag) {
|
|
85
|
+
bag = {
|
|
86
|
+
sharedDir: session.sharedDir || null,
|
|
87
|
+
counts: _zeroCounts(),
|
|
88
|
+
recent: [],
|
|
89
|
+
dirty: false,
|
|
90
|
+
flushTimer: null,
|
|
91
|
+
};
|
|
92
|
+
_state.set(sid, bag);
|
|
93
|
+
}
|
|
94
|
+
// sharedDir may not be available at session creation — keep latest.
|
|
95
|
+
if (session.sharedDir) bag.sharedDir = session.sharedDir;
|
|
96
|
+
return bag;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Record a routing fallback event.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} session — crew session (must have .id; .sharedDir for flush)
|
|
103
|
+
* @param {string} reason — one of ROUTING_REASONS
|
|
104
|
+
* @param {object} [meta]
|
|
105
|
+
* @param {string} [meta.fromRole]
|
|
106
|
+
* @param {string} [meta.toRole]
|
|
107
|
+
* @param {string} [meta.taskId]
|
|
108
|
+
* @param {string} [meta.note]
|
|
109
|
+
* @returns {boolean} true if recorded; false if invalid input
|
|
110
|
+
*/
|
|
111
|
+
export function recordRoutingEvent(session, reason, meta = {}) {
|
|
112
|
+
if (!session || !session.id) return false;
|
|
113
|
+
if (!REASON_SET.has(reason)) {
|
|
114
|
+
console.warn(`[routing-metrics] Unknown reason: ${reason} (allowed: ${ROUTING_REASONS.join(', ')})`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const bag = _getOrInit(session);
|
|
118
|
+
if (!bag) return false;
|
|
119
|
+
|
|
120
|
+
bag.counts[reason] = (bag.counts[reason] || 0) + 1;
|
|
121
|
+
bag.recent.push({
|
|
122
|
+
ts: Date.now(),
|
|
123
|
+
reason,
|
|
124
|
+
fromRole: meta.fromRole || null,
|
|
125
|
+
toRole: meta.toRole || null,
|
|
126
|
+
taskId: meta.taskId || null,
|
|
127
|
+
note: meta.note || null,
|
|
128
|
+
});
|
|
129
|
+
// Bound the ring.
|
|
130
|
+
if (bag.recent.length > RECENT_RING_SIZE) {
|
|
131
|
+
bag.recent.splice(0, bag.recent.length - RECENT_RING_SIZE);
|
|
132
|
+
}
|
|
133
|
+
bag.dirty = true;
|
|
134
|
+
_ensureTimer(session.id, bag);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _ensureTimer(sessionId, bag) {
|
|
139
|
+
if (bag.flushTimer) return;
|
|
140
|
+
bag.flushTimer = setTimeout(() => {
|
|
141
|
+
bag.flushTimer = null;
|
|
142
|
+
_flush(sessionId, bag).catch((e) =>
|
|
143
|
+
console.warn(`[routing-metrics] periodic flush failed for ${sessionId}: ${e.message}`),
|
|
144
|
+
);
|
|
145
|
+
}, FLUSH_INTERVAL_MS);
|
|
146
|
+
// Don't keep the event loop alive solely for metrics flush.
|
|
147
|
+
if (typeof bag.flushTimer.unref === 'function') bag.flushTimer.unref();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function _flush(sessionId, bag) {
|
|
151
|
+
if (!bag.dirty) return;
|
|
152
|
+
if (!bag.sharedDir) return; // can't flush without target dir
|
|
153
|
+
const dir = join(bag.sharedDir, 'context');
|
|
154
|
+
const file = join(dir, 'routing-metrics.json');
|
|
155
|
+
const payload = {
|
|
156
|
+
schemaVersion: 1,
|
|
157
|
+
lastFlushedAt: Date.now(),
|
|
158
|
+
counts: { ...bag.counts },
|
|
159
|
+
recent: bag.recent.slice(),
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
await fs.mkdir(dir, { recursive: true });
|
|
163
|
+
// Write-then-rename for atomicity (single-line file is small; tolerate
|
|
164
|
+
// platform quirks).
|
|
165
|
+
const tmp = `${file}.tmp`;
|
|
166
|
+
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
167
|
+
await fs.rename(tmp, file);
|
|
168
|
+
bag.dirty = false;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.warn(`[routing-metrics] flush write failed: ${e.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Force a synchronous-ish flush (still returns a Promise). Useful from
|
|
176
|
+
* shutdown paths or tests.
|
|
177
|
+
*/
|
|
178
|
+
export async function flushRoutingMetricsNow(session) {
|
|
179
|
+
const bag = _state.get(session?.id);
|
|
180
|
+
if (!bag) return;
|
|
181
|
+
if (bag.flushTimer) {
|
|
182
|
+
clearTimeout(bag.flushTimer);
|
|
183
|
+
bag.flushTimer = null;
|
|
184
|
+
}
|
|
185
|
+
await _flush(session.id, bag);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read current counts (test/inspection only; non-mutating snapshot).
|
|
190
|
+
* @returns {{ counts: Record<string, number>, recent: Array<object> } | null}
|
|
191
|
+
*/
|
|
192
|
+
export function getRoutingMetricsSnapshot(session) {
|
|
193
|
+
const bag = _state.get(session?.id);
|
|
194
|
+
if (!bag) return null;
|
|
195
|
+
return {
|
|
196
|
+
counts: { ...bag.counts },
|
|
197
|
+
recent: bag.recent.slice(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reset (test-only).
|
|
203
|
+
*/
|
|
204
|
+
export function _resetRoutingMetricsForTest(sessionId) {
|
|
205
|
+
if (sessionId) {
|
|
206
|
+
const bag = _state.get(sessionId);
|
|
207
|
+
if (bag?.flushTimer) clearTimeout(bag.flushTimer);
|
|
208
|
+
_state.delete(sessionId);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const [, bag] of _state) {
|
|
212
|
+
if (bag.flushTimer) clearTimeout(bag.flushTimer);
|
|
213
|
+
}
|
|
214
|
+
_state.clear();
|
|
215
|
+
}
|
package/crew/routing.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Crew — 路由解析与执行
|
|
3
3
|
* parseRoutes, executeRoute, buildRoutePrompt, dispatchToRole
|
|
4
|
+
*
|
|
5
|
+
* task-330c — Greedy-strip guard:
|
|
6
|
+
* ⚠️ ROUTE-block stripping lives in `parseRoutes()` ONLY. Callers that
|
|
7
|
+
* want the role's prose without ROUTE blocks must consume
|
|
8
|
+
* `parseRoutes(text).displayBody` — never run a second
|
|
9
|
+
* `text.replace(/---ROUTE---[\s\S]*$/g, '')` style strip on already
|
|
10
|
+
* parser-cleaned text. A second strip would (a) re-process text
|
|
11
|
+
* that no longer has ROUTE markers (no-op at best, miscut at worst),
|
|
12
|
+
* (b) reintroduce the greedy tail-eating bug fixed by task-328.
|
|
13
|
+
* The summary-fallback in role-output.js and the recent-routes
|
|
14
|
+
* injector below both honour this contract.
|
|
4
15
|
*/
|
|
5
16
|
import { join } from 'path';
|
|
6
17
|
import { sendCrewMessage, sendCrewOutput, sendStatusUpdate } from './ui-messages.js';
|
|
7
18
|
import { ensureTaskFile, appendTaskRecord, readTaskFile, updateKanban, readKanban, saveRoleWorkSummary } from './task-files.js';
|
|
8
19
|
import { createRoleQuery, clearRoleSessionId } from './role-query.js';
|
|
9
20
|
import { saveSessionMeta } from './persistence.js';
|
|
21
|
+
import { recordRoutingEvent } from './routing-metrics.js';
|
|
10
22
|
import ctx from '../context.js';
|
|
11
23
|
|
|
12
24
|
/** Format role label */
|
|
@@ -14,6 +26,54 @@ function roleLabel(r) {
|
|
|
14
26
|
return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
/**
|
|
30
|
+
* task-330c — Smart truncate for recent-routes / history snippets.
|
|
31
|
+
*
|
|
32
|
+
* Cuts at a sentence/line boundary when possible to avoid mid-sentence
|
|
33
|
+
* truncation; falls back to a hard cut when no good boundary exists in
|
|
34
|
+
* the candidate window. Always appends a marker so downstream readers
|
|
35
|
+
* (LLM roles seeing recent-routes context) know the full text lives
|
|
36
|
+
* elsewhere (feature file).
|
|
37
|
+
*
|
|
38
|
+
* Boundary detection: looks for the last period (`.` `。` `!` `?` `!` `?`)
|
|
39
|
+
* or newline inside the window `[Math.floor(max*0.7), max)`. The 70% lower
|
|
40
|
+
* bound is a quality floor — we don't want to cut so early that we lose
|
|
41
|
+
* meaningful tail context just to hit a clean boundary.
|
|
42
|
+
*
|
|
43
|
+
* Idempotent: text already short enough is returned unchanged (no marker).
|
|
44
|
+
*
|
|
45
|
+
* @param {string} text — input string (may be any length)
|
|
46
|
+
* @param {number} max — maximum chars before truncation
|
|
47
|
+
* @returns {string} — original text or `<truncated>…(truncated, full in feature file)`
|
|
48
|
+
*/
|
|
49
|
+
const TRUNCATE_MARKER = '…(truncated, full in feature file)';
|
|
50
|
+
export function smartTruncate(text, max) {
|
|
51
|
+
if (typeof text !== 'string') return '';
|
|
52
|
+
if (!Number.isFinite(max) || max <= 0) return '';
|
|
53
|
+
if (text.length <= max) return text;
|
|
54
|
+
|
|
55
|
+
// Search window: prefer cuts in the last 30% of the limit.
|
|
56
|
+
const windowStart = Math.floor(max * 0.7);
|
|
57
|
+
const windowSlice = text.slice(windowStart, max);
|
|
58
|
+
// Last sentence boundary in window — period family OR newline.
|
|
59
|
+
// We accept a boundary char and cut AFTER it so the sentence stays whole.
|
|
60
|
+
const BOUNDARY_RE = /[.。!?!?\n]/g;
|
|
61
|
+
let bestIdx = -1;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = BOUNDARY_RE.exec(windowSlice)) !== null) {
|
|
64
|
+
bestIdx = m.index;
|
|
65
|
+
}
|
|
66
|
+
let cutEnd;
|
|
67
|
+
if (bestIdx !== -1) {
|
|
68
|
+
cutEnd = windowStart + bestIdx + 1; // include the boundary char itself
|
|
69
|
+
} else {
|
|
70
|
+
cutEnd = max; // no boundary in window → hard cut
|
|
71
|
+
}
|
|
72
|
+
// Trim trailing whitespace from the cut piece for cleaner output.
|
|
73
|
+
const head = text.slice(0, cutEnd).replace(/\s+$/, '');
|
|
74
|
+
return `${head}${TRUNCATE_MARKER}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
17
77
|
/**
|
|
18
78
|
* Append text to content — works for both string and multimodal array content.
|
|
19
79
|
* For arrays, appends to the last text block (or adds a new one).
|
|
@@ -33,82 +93,257 @@ function _appendTextToContent(content, text) {
|
|
|
33
93
|
}
|
|
34
94
|
|
|
35
95
|
/**
|
|
36
|
-
* 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task
|
|
37
|
-
*
|
|
96
|
+
* 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)。
|
|
97
|
+
*
|
|
98
|
+
* task-328 — Returns a structured result:
|
|
99
|
+
* { routes, displayBody }
|
|
100
|
+
*
|
|
101
|
+
* - `routes` — Array<{to, summary, taskId, taskTitle}> (same shape as before)
|
|
102
|
+
* - `displayBody` — original text MINUS the exact matched ROUTE ranges
|
|
103
|
+
* (including any surrounding ```fence``` that wraps the
|
|
104
|
+
* ROUTE block), preserving everything else verbatim.
|
|
105
|
+
*
|
|
106
|
+
* Backward compatibility: the returned object is also iterable as an array
|
|
107
|
+
* of routes for any legacy caller that does `for (const r of parseRoutes(...))`
|
|
108
|
+
* or `parseRoutes(...).length` — we attach `[Symbol.iterator]`, `length`, and
|
|
109
|
+
* numeric index properties mirroring `routes`. New callers should use the
|
|
110
|
+
* named `.routes` / `.displayBody` fields.
|
|
111
|
+
*
|
|
112
|
+
* Scope A — ROUTE parser tolerance (task-328):
|
|
113
|
+
* (1) Markdown fence-wrapped ROUTE blocks are still parsed AND the fence
|
|
114
|
+
* lines are stripped from displayBody (so the user doesn't see an
|
|
115
|
+
* empty ```…```).
|
|
116
|
+
* (2) END variants accepted: ---END_ROUTE--- / ---END ROUTE--- /
|
|
117
|
+
* ---END--- / ---END:--- / ---END-ROUTE--- / ---endroute---.
|
|
118
|
+
* (3) `to:` accepts: `to:` `to :` `to:` `TO:` with any casing.
|
|
119
|
+
* (4) Phase 2 soft-end is a STRUCTURAL signal (blank line + `---`, or
|
|
120
|
+
* `<kanban>` / `<recent-routes>` / `<task-context>`), NOT a bare blank
|
|
121
|
+
* line — so multi-paragraph summaries are not truncated.
|
|
122
|
+
* (5) Pre-pass: fenced code is MASKED (positions preserved) but a fence
|
|
123
|
+
* that contains `---ROUTE---` is NOT masked — the ROUTE inside the
|
|
124
|
+
* fence is the real one (matches what users write).
|
|
125
|
+
*
|
|
126
|
+
* Scope B — non-ROUTE body preservation (task-328):
|
|
127
|
+
* - `displayBody` = original minus the EXACT matched ROUTE ranges.
|
|
128
|
+
* No greedy "strip-to-EOF" anymore — post-ROUTE text survives.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text - Raw role output (may contain 0+ ROUTE blocks)
|
|
131
|
+
* @returns {{ routes: Array<{to:string,summary:string,taskId:string|null,taskTitle:string|null}>, displayBody: string } & Iterable}
|
|
38
132
|
*/
|
|
39
133
|
export function parseRoutes(text) {
|
|
134
|
+
const input = typeof text === 'string' ? text : '';
|
|
40
135
|
const routes = [];
|
|
136
|
+
// Exact character ranges (in ORIGINAL `input`) to remove from displayBody.
|
|
137
|
+
// Each entry: { start, end } — half-open, end exclusive.
|
|
138
|
+
const strippedRanges = [];
|
|
41
139
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
140
|
+
if (!input) return _wrapParseResult(routes, '', strippedRanges);
|
|
141
|
+
|
|
142
|
+
// ─── Pre-pass §2: mask fenced code WITHOUT stripping from original ──
|
|
143
|
+
// We build a boolean mask the same length as `input`. Fences are walked
|
|
144
|
+
// left-to-right. A fence containing `---ROUTE---` is SKIPPED (not masked)
|
|
145
|
+
// so the real ROUTE inside it can be parsed by Phase 1. Non-ROUTE fences
|
|
146
|
+
// are masked so any ```example``` won't pollute Phase 1/2/3 matching.
|
|
147
|
+
//
|
|
148
|
+
// We also remember the start/end of each "ROUTE-carrying fence" so the
|
|
149
|
+
// displayBody calculation can extend a ROUTE match to cover its fence
|
|
150
|
+
// lines — otherwise the user would see an empty ```…``` left behind.
|
|
151
|
+
const masked = _maskNonRouteFences(input);
|
|
152
|
+
const maskedText = masked.text; // original chars or ' ' for masked regions
|
|
153
|
+
const routeFences = masked.routeFences; // [{start, end, innerStart, innerEnd}, ...]
|
|
45
154
|
|
|
46
155
|
// ─── Phase 1: Standard ROUTE blocks (with closing marker) ─────
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const
|
|
156
|
+
// Accept END variants: END_ROUTE | END ROUTE | END-ROUTE | END: | END | endroute
|
|
157
|
+
// Body capture uses negative lookahead to avoid crossing another opener.
|
|
158
|
+
// We run regex on `maskedText` so quoted examples (in non-ROUTE fences)
|
|
159
|
+
// don't match, but we use match.index to index into the ORIGINAL input
|
|
160
|
+
// when computing the strip range.
|
|
161
|
+
const closedRegex = /---\s*ROUTE\s*---\s*\r?\n((?:(?!---\s*ROUTE\s*---)[\s\S])*?)---\s*(?:END[_ \-]?ROUTE|ENDROUTE|END)\s*:?\s*---/gi;
|
|
53
162
|
let match;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
while ((match = regex.exec(text)) !== null) {
|
|
57
|
-
matchedRanges.push({ start: match.index, end: match.index + match[0].length });
|
|
163
|
+
while ((match = closedRegex.exec(maskedText)) !== null) {
|
|
58
164
|
const parsed = _parseRouteBlock(match[1]);
|
|
165
|
+
let rangeStart = match.index;
|
|
166
|
+
let rangeEnd = match.index + match[0].length;
|
|
167
|
+
// §5: if this match lives inside a ROUTE-carrying fence, extend the
|
|
168
|
+
// strip to cover the fence lines (so the UI doesn't see empty ```…```).
|
|
169
|
+
const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
|
|
170
|
+
if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
|
|
171
|
+
strippedRanges.push({ start: rangeStart, end: rangeEnd });
|
|
59
172
|
if (parsed) routes.push(parsed);
|
|
60
173
|
}
|
|
61
174
|
|
|
62
|
-
// ─── Phase 2: Fallback — ROUTE block
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
175
|
+
// ─── Phase 2: Fallback — ROUTE block with no closing marker ──
|
|
176
|
+
// Soft-end uses a STRUCTURAL signal, not a bare blank line. A summary
|
|
177
|
+
// can span multiple paragraphs — it ends only when we see:
|
|
178
|
+
// (a) another ---ROUTE--- opener, or
|
|
179
|
+
// (b) EOF, or
|
|
180
|
+
// (c) a structural separator after ≥2 consecutive newlines:
|
|
181
|
+
// `\n\s*\n+---` (blank line + ---)
|
|
182
|
+
// `\n\s*\n+<(kanban|recent-routes|task-context|EOF)`
|
|
183
|
+
// (d) the 2048-char hard cap (safety valve for runaway blocks).
|
|
184
|
+
const openRegex = /---\s*ROUTE\s*---\s*\r?\n/gi;
|
|
185
|
+
while ((match = openRegex.exec(maskedText)) !== null) {
|
|
186
|
+
const openStart = match.index;
|
|
187
|
+
// Skip if this opener was already consumed by a Phase 1 match.
|
|
188
|
+
if (strippedRanges.some(r => openStart >= r.start && openStart < r.end)) continue;
|
|
189
|
+
|
|
190
|
+
const blockStart = openStart + match[0].length;
|
|
191
|
+
// (a) next opener?
|
|
192
|
+
const nextOpen = maskedText.indexOf('---ROUTE---', blockStart);
|
|
193
|
+
const hardEnd = nextOpen !== -1 ? nextOpen : maskedText.length;
|
|
194
|
+
const scope = maskedText.slice(blockStart, hardEnd);
|
|
195
|
+
|
|
196
|
+
// (c) structural cutoff — scan for the first structural signal after
|
|
197
|
+
// ≥2 consecutive newlines (blank line + structure).
|
|
198
|
+
const SOFT_END_RE = /\n[ \t]*\n+(?:---(?!\s*ROUTE)|<(?:kanban|recent-routes|task-context)\b)/;
|
|
199
|
+
const softMatch = scope.match(SOFT_END_RE);
|
|
200
|
+
let blockEnd = hardEnd;
|
|
201
|
+
if (softMatch && softMatch.index != null) {
|
|
202
|
+
blockEnd = blockStart + softMatch.index;
|
|
203
|
+
}
|
|
73
204
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const hardEnd = nextRoute !== -1 ? nextRoute : text.length;
|
|
78
|
-
// Soft end: first blank line (two or more newlines with only whitespace in between).
|
|
79
|
-
const blank = text.slice(blockStart, hardEnd).search(/\n[ \t]*\n/);
|
|
80
|
-
const blockEnd = blank !== -1 ? blockStart + blank : hardEnd;
|
|
81
|
-
const block = text.slice(blockStart, blockEnd);
|
|
205
|
+
// (d) 2048-char hard cap — protect against runaway unclosed blocks.
|
|
206
|
+
const SUMMARY_CAP = 2048;
|
|
207
|
+
if (blockEnd - blockStart > SUMMARY_CAP) blockEnd = blockStart + SUMMARY_CAP;
|
|
82
208
|
|
|
209
|
+
const block = maskedText.slice(blockStart, blockEnd);
|
|
83
210
|
const parsed = _parseRouteBlock(block);
|
|
211
|
+
|
|
212
|
+
let rangeStart = openStart;
|
|
213
|
+
let rangeEnd = blockEnd;
|
|
214
|
+
// Extend to fence if wrapped.
|
|
215
|
+
const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
|
|
216
|
+
if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
|
|
217
|
+
strippedRanges.push({ start: rangeStart, end: rangeEnd });
|
|
84
218
|
if (parsed) routes.push(parsed);
|
|
85
219
|
}
|
|
86
220
|
|
|
87
221
|
// ─── Phase 3: Shorthand — "ROUTE → target" / "ROUTE: target" ─
|
|
88
|
-
//
|
|
89
|
-
//
|
|
222
|
+
// Only matches a single line and only outside any ROUTE block. We also
|
|
223
|
+
// run this on maskedText so shorthand inside quoted fences is ignored.
|
|
90
224
|
const shorthandRegex = /^ROUTE\s*[→:]\s*(\S+)[,:\s]*(.*)$/gm;
|
|
91
|
-
while ((match = shorthandRegex.exec(
|
|
92
|
-
// Skip if inside an already-matched ROUTE block range
|
|
225
|
+
while ((match = shorthandRegex.exec(maskedText)) !== null) {
|
|
93
226
|
const pos = match.index;
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
227
|
+
if (strippedRanges.some(r => pos >= r.start && pos < r.end)) continue;
|
|
228
|
+
|
|
229
|
+
// Also skip if inside an open ---ROUTE--- block (even unclosed).
|
|
230
|
+
const precedingText = maskedText.slice(0, pos);
|
|
231
|
+
const lastRouteOpen = precedingText.search(/---\s*ROUTE\s*---(?![\s\S]*---\s*ROUTE\s*---)/i);
|
|
232
|
+
const lastRouteOpenIdx = precedingText.lastIndexOf('---ROUTE---');
|
|
233
|
+
const lastRouteCloseIdx = Math.max(
|
|
99
234
|
precedingText.lastIndexOf('---END_ROUTE---'),
|
|
100
235
|
precedingText.lastIndexOf('---END ROUTE---'),
|
|
101
|
-
precedingText.lastIndexOf('---END---')
|
|
236
|
+
precedingText.lastIndexOf('---END-ROUTE---'),
|
|
237
|
+
precedingText.lastIndexOf('---END---'),
|
|
102
238
|
);
|
|
103
|
-
if (
|
|
239
|
+
if (lastRouteOpenIdx > lastRouteCloseIdx) continue;
|
|
240
|
+
void lastRouteOpen; // silence unused
|
|
104
241
|
|
|
105
242
|
const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
|
|
106
243
|
const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
|
|
107
244
|
|
|
108
245
|
routes.push({ to: toRaw, summary, taskId: null, taskTitle: null });
|
|
246
|
+
// Shorthand is a single line — strip the whole line.
|
|
247
|
+
const lineEnd = maskedText.indexOf('\n', pos);
|
|
248
|
+
strippedRanges.push({
|
|
249
|
+
start: pos,
|
|
250
|
+
end: lineEnd === -1 ? maskedText.length : lineEnd,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const displayBody = _removeRanges(input, strippedRanges);
|
|
255
|
+
return _wrapParseResult(routes, displayBody, strippedRanges);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Wrap the parse result in an object that is ALSO iterable as an array
|
|
260
|
+
* of routes (for legacy `for (const r of parseRoutes(x))` callers) and
|
|
261
|
+
* supports `.length` / numeric index. New fields: `.routes`, `.displayBody`.
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
function _wrapParseResult(routes, displayBody, rangesForDebug) {
|
|
265
|
+
// Start from a real Array so `Array.isArray()` and iteration/indexing
|
|
266
|
+
// "just work". Decorate with named fields that new callers prefer.
|
|
267
|
+
const arr = routes.slice();
|
|
268
|
+
Object.defineProperty(arr, 'routes', { value: routes, enumerable: false });
|
|
269
|
+
Object.defineProperty(arr, 'displayBody', { value: displayBody, enumerable: false });
|
|
270
|
+
Object.defineProperty(arr, 'strippedRanges', { value: rangesForDebug, enumerable: false });
|
|
271
|
+
return arr;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* §2 helper — build a mask of `input` that replaces non-ROUTE fenced
|
|
276
|
+
* code with spaces (length-preserving), and records the positions of
|
|
277
|
+
* fences that DO contain a ROUTE opener (so Phase 1/2 can extend their
|
|
278
|
+
* strip range to swallow the fence lines).
|
|
279
|
+
*
|
|
280
|
+
* @param {string} input
|
|
281
|
+
* @returns {{ text: string, routeFences: Array<{start:number,end:number,innerStart:number,innerEnd:number}> }}
|
|
282
|
+
* @private
|
|
283
|
+
*/
|
|
284
|
+
function _maskNonRouteFences(input) {
|
|
285
|
+
const FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
|
|
286
|
+
let m;
|
|
287
|
+
let out = '';
|
|
288
|
+
let lastIdx = 0;
|
|
289
|
+
const routeFences = [];
|
|
290
|
+
while ((m = FENCE_RE.exec(input)) !== null) {
|
|
291
|
+
const fenceStart = m.index;
|
|
292
|
+
const fenceEnd = m.index + m[0].length;
|
|
293
|
+
const innerStart = fenceStart + m[0].indexOf('\n') + 1;
|
|
294
|
+
const innerEnd = fenceEnd - 3; // strip trailing ```
|
|
295
|
+
const fenceContent = m[1];
|
|
296
|
+
const hasRoute = /---\s*ROUTE\s*---/i.test(fenceContent);
|
|
297
|
+
// Copy unchanged text up to fence start
|
|
298
|
+
out += input.slice(lastIdx, fenceStart);
|
|
299
|
+
if (hasRoute) {
|
|
300
|
+
// Keep the fence content intact so Phase 1 sees the ROUTE; record
|
|
301
|
+
// the fence range for the displayBody extender.
|
|
302
|
+
out += input.slice(fenceStart, fenceEnd);
|
|
303
|
+
routeFences.push({ start: fenceStart, end: fenceEnd, innerStart, innerEnd });
|
|
304
|
+
} else {
|
|
305
|
+
// Mask entire fence (including markers) with spaces of equal length
|
|
306
|
+
// so positions line up with the original string.
|
|
307
|
+
out += ' '.repeat(fenceEnd - fenceStart);
|
|
308
|
+
}
|
|
309
|
+
lastIdx = fenceEnd;
|
|
109
310
|
}
|
|
311
|
+
out += input.slice(lastIdx);
|
|
312
|
+
return { text: out, routeFences };
|
|
313
|
+
}
|
|
110
314
|
|
|
111
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Remove a list of (possibly overlapping) character ranges from `input`.
|
|
317
|
+
* Also trims leading/trailing whitespace from the resulting blocks so the
|
|
318
|
+
* displayBody doesn't keep lonely blank lines where a ROUTE used to be.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} input
|
|
321
|
+
* @param {Array<{start:number, end:number}>} ranges
|
|
322
|
+
* @returns {string}
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
function _removeRanges(input, ranges) {
|
|
326
|
+
if (!ranges || ranges.length === 0) return input;
|
|
327
|
+
// Merge overlapping/adjacent ranges.
|
|
328
|
+
const sorted = ranges.slice().sort((a, b) => a.start - b.start);
|
|
329
|
+
const merged = [sorted[0]];
|
|
330
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
331
|
+
const prev = merged[merged.length - 1];
|
|
332
|
+
const cur = sorted[i];
|
|
333
|
+
if (cur.start <= prev.end) prev.end = Math.max(prev.end, cur.end);
|
|
334
|
+
else merged.push({ ...cur });
|
|
335
|
+
}
|
|
336
|
+
// Build output by keeping the gaps between merged ranges.
|
|
337
|
+
let out = '';
|
|
338
|
+
let cursor = 0;
|
|
339
|
+
for (const r of merged) {
|
|
340
|
+
out += input.slice(cursor, r.start);
|
|
341
|
+
cursor = r.end;
|
|
342
|
+
}
|
|
343
|
+
out += input.slice(cursor);
|
|
344
|
+
// Collapse 3+ consecutive newlines (left by a removal) to a double newline.
|
|
345
|
+
out = out.replace(/\n{3,}/g, '\n\n');
|
|
346
|
+
return out.trim();
|
|
112
347
|
}
|
|
113
348
|
|
|
114
349
|
/**
|
|
@@ -117,7 +352,10 @@ export function parseRoutes(text) {
|
|
|
117
352
|
* @returns {{ to: string, summary: string, taskId: string|null, taskTitle: string|null } | null}
|
|
118
353
|
*/
|
|
119
354
|
function _parseRouteBlock(block) {
|
|
120
|
-
|
|
355
|
+
// task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
|
|
356
|
+
// and stray whitespace before the colon (`to :`). All field separators accept
|
|
357
|
+
// either ASCII `:` or Chinese `:`.
|
|
358
|
+
const toMatch = block.match(/to\s*[::]\s*(.+)/i);
|
|
121
359
|
if (!toMatch) return null;
|
|
122
360
|
|
|
123
361
|
// ★ Clean `to` value: take only the first word (strip parenthetical notes, extra text)
|
|
@@ -126,10 +364,11 @@ function _parseRouteBlock(block) {
|
|
|
126
364
|
// Strip trailing punctuation (commas, semicolons, colons, etc.)
|
|
127
365
|
const toClean = toRaw.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
|
|
128
366
|
|
|
129
|
-
// ★ summary: match until next known field (task:/taskTitle:) or end of block
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
367
|
+
// ★ summary: match until next known field (task:/taskTitle:) or end of block.
|
|
368
|
+
// Field separator accepts ASCII `:` or Chinese `:`.
|
|
369
|
+
const summaryMatch = block.match(/summary\s*[::]\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*[::]|$)/i);
|
|
370
|
+
const taskMatch = block.match(/^task\s*[::]\s*(.+)/im);
|
|
371
|
+
const taskTitleMatch = block.match(/^taskTitle\s*[::]\s*(.+)/im);
|
|
133
372
|
|
|
134
373
|
let summary = summaryMatch ? summaryMatch[1].trim() : '';
|
|
135
374
|
|
|
@@ -137,7 +376,7 @@ function _parseRouteBlock(block) {
|
|
|
137
376
|
// just write the message as free text AFTER the known fields. Collect
|
|
138
377
|
// everything that is NOT a recognised field line as the body.
|
|
139
378
|
if (!summary) {
|
|
140
|
-
const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s
|
|
379
|
+
const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s*[::]/i;
|
|
141
380
|
const bare = block
|
|
142
381
|
.split(/\r?\n/)
|
|
143
382
|
.filter(line => !KNOWN_FIELD.test(line))
|
|
@@ -237,6 +476,77 @@ export function resolveRoleName(to, session, fromRole) {
|
|
|
237
476
|
export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
238
477
|
let { to, summary, taskId, taskTitle } = route;
|
|
239
478
|
|
|
479
|
+
// ─── task-330a §A + task-330b §B: self-route hard-reject + metric ───
|
|
480
|
+
// 福勒 Final Spec §A — `route.to` 等同于发送方时直接拒绝,不消费 turn、
|
|
481
|
+
// 不写 kanban、不 dispatch、不 round++(round 已由 role-output 计数)。
|
|
482
|
+
// 解析顺序:先尝试用 resolveRoleName 还原 alias(pm/dev/displayName/
|
|
483
|
+
// pm-乔布斯 等),命中即比较;未命中则退回原始字符串大小写不敏感比较。
|
|
484
|
+
// 拒绝时:先写 330b 的 routing-metrics.json 持久化 metric(observer 路径),
|
|
485
|
+
// 再 emit 330a 的 sendCrewMessage UI 卡片,最后 return(不消费 turn)。
|
|
486
|
+
// alias self-route 漏记 metric 已记入 PM backlog 作 follow-up(330b 的
|
|
487
|
+
// raw 比较 `to === fromRole` 仅命中字面相同的情况;alias 形式由 330a
|
|
488
|
+
// 的 isSelf 兜底,但 330b 的 raw 检查保留为快速路径 + 兼容)。
|
|
489
|
+
if (to !== 'human') {
|
|
490
|
+
const resolvedSelfCheck = resolveRoleName(to, session, fromRole);
|
|
491
|
+
const isSelf = resolvedSelfCheck === fromRole
|
|
492
|
+
|| (typeof to === 'string' && to.toLowerCase() === String(fromRole).toLowerCase());
|
|
493
|
+
if (isSelf) {
|
|
494
|
+
console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'})`);
|
|
495
|
+
// 330b path — persistent metric counter (routing-metrics.json + ring).
|
|
496
|
+
// Always-safe; never throws (recordRoutingEvent degrades to console.warn).
|
|
497
|
+
recordRoutingEvent(session, 'self-route', {
|
|
498
|
+
fromRole,
|
|
499
|
+
toRole: to,
|
|
500
|
+
taskId: taskId || null,
|
|
501
|
+
note: 'route.to === fromRole at executeRoute entry (rejected by §A)',
|
|
502
|
+
});
|
|
503
|
+
// 330a path — UI broadcast so the role sees rejection in transcript.
|
|
504
|
+
try {
|
|
505
|
+
sendCrewMessage({
|
|
506
|
+
type: 'routing-metrics',
|
|
507
|
+
sessionId: session.id,
|
|
508
|
+
event: 'route_rejected',
|
|
509
|
+
reason: 'self-route',
|
|
510
|
+
fromRole,
|
|
511
|
+
to,
|
|
512
|
+
taskId: taskId || null,
|
|
513
|
+
timestamp: Date.now(),
|
|
514
|
+
});
|
|
515
|
+
} catch (e) {
|
|
516
|
+
console.warn('[Crew] Failed to emit routing-metrics:', e.message);
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
sendCrewMessage({
|
|
520
|
+
type: 'crew_route_rejected',
|
|
521
|
+
sessionId: session.id,
|
|
522
|
+
fromRole,
|
|
523
|
+
to,
|
|
524
|
+
reason: 'self-route',
|
|
525
|
+
message: `自路由被拒绝:${fromRole} 不能给自己发消息。请改用 task_close(taskId, summary) 关闭任务,或 role_standby(role) 进入待命,或选择其他角色作为 ROUTE 目标。`,
|
|
526
|
+
taskId: taskId || null,
|
|
527
|
+
});
|
|
528
|
+
} catch (e) {
|
|
529
|
+
console.warn('[Crew] Failed to emit crew_route_rejected:', e.message);
|
|
530
|
+
}
|
|
531
|
+
// Do NOT decrement session.round — role-output.js already incremented
|
|
532
|
+
// it for this whole turn batch; one rejected route doesn't undo the
|
|
533
|
+
// turn (other routes in the same batch may still be valid).
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// task-330b §B item 1: state-stopped metric — message arrived while
|
|
539
|
+
// session was paused/stopped. Behaviour (auto-resume) is unchanged for
|
|
540
|
+
// backward compat; this is observer-only.
|
|
541
|
+
if (session.status === 'paused' || session.status === 'stopped') {
|
|
542
|
+
recordRoutingEvent(session, 'state-stopped', {
|
|
543
|
+
fromRole,
|
|
544
|
+
toRole: to,
|
|
545
|
+
taskId: taskId || null,
|
|
546
|
+
note: `session.status=${session.status} at executeRoute entry`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
240
550
|
// Auto-resume: paused/stopped → running (route execution means work should continue)
|
|
241
551
|
if (session.status === 'paused' || session.status === 'stopped') {
|
|
242
552
|
console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${to})`);
|
|
@@ -441,11 +751,21 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
|
|
|
441
751
|
}
|
|
442
752
|
|
|
443
753
|
// 最近路由消息注入(帮助 clear 后的角色恢复上下文)
|
|
754
|
+
// task-330c: each entry is smart-truncated to 400 chars at a sentence
|
|
755
|
+
// boundary (period/newline) so we don't slice key info mid-sentence.
|
|
756
|
+
// The full content lives in the feature file — the marker tells the
|
|
757
|
+
// role where to look if they need more context. The pre-stored
|
|
758
|
+
// `m.content` was already truncated to 200 (history step below) until
|
|
759
|
+
// task-330c bumped it to 400 + smart boundary.
|
|
760
|
+
// ⚠️ DO NOT pass `m.content` through any greedy `.replace(/.../g, '')`
|
|
761
|
+
// here — it has already been derived from displayBody at message
|
|
762
|
+
// time (parser-stripped), and a second strip would re-process
|
|
763
|
+
// text that no longer holds ROUTE markers. See _appendHistory below.
|
|
444
764
|
if (session.messageHistory.length > 0) {
|
|
445
765
|
const recentRoutes = session.messageHistory
|
|
446
766
|
.filter(m => m.from !== 'system')
|
|
447
767
|
.slice(-5)
|
|
448
|
-
.map(m => `[${m.from} → ${m.to}${m.taskId ? ` (${m.taskId})` : ''}] ${m.content}`)
|
|
768
|
+
.map(m => `[${m.from} → ${m.to}${m.taskId ? ` (${m.taskId})` : ''}] ${smartTruncate(m.content, 400)}`)
|
|
449
769
|
.join('\n');
|
|
450
770
|
if (recentRoutes) {
|
|
451
771
|
const ctx = `\n\n---\n<recent-routes>\n${recentRoutes}\n</recent-routes>`;
|
|
@@ -454,9 +774,15 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
|
|
|
454
774
|
}
|
|
455
775
|
|
|
456
776
|
// 记录消息历史
|
|
777
|
+
// task-330c: cap raised 200 → 400 to match the recent-routes injection
|
|
778
|
+
// window. Keeping the pre-store cap at 200 would pin every entry below
|
|
779
|
+
// smartTruncate's 400 threshold, making the smart-truncate boundary cut
|
|
780
|
+
// a permanent no-op in production. 400 here lets longer messages flow
|
|
781
|
+
// into history; smartTruncate trims them at sentence boundaries when
|
|
782
|
+
// injected into <recent-routes>.
|
|
457
783
|
const historyContent = typeof content === 'string'
|
|
458
|
-
? content.substring(0,
|
|
459
|
-
: (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0,
|
|
784
|
+
? content.substring(0, 400)
|
|
785
|
+
: (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0, 400) + (content.some(b => b.type === 'image') ? ' [+images]' : '') : '...');
|
|
460
786
|
session.messageHistory.push({
|
|
461
787
|
from: fromSource,
|
|
462
788
|
to: roleName,
|
package/package.json
CHANGED
package/unify/web-bridge.js
CHANGED
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
* 3. Engine.query() yields events → translated into unify_output messages
|
|
12
12
|
* that carry { conversationId, data } in claude_output format.
|
|
13
13
|
* 4. The frontend's handleUnifyOutput dispatches them through handleClaudeOutput.
|
|
14
|
+
*
|
|
15
|
+
* task-330c lint guard:
|
|
16
|
+
* ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
|
|
17
|
+
* style strips on incoming/outgoing message bodies. Crew ROUTE
|
|
18
|
+
* stripping is owned EXCLUSIVELY by `agent/crew/routing.js`
|
|
19
|
+
* `parseRoutes()` which returns `{routes, displayBody}` with exact
|
|
20
|
+
* ranges removed. Re-stripping here would (a) double-eat content
|
|
21
|
+
* that has already been parser-cleaned, (b) reintroduce the bug
|
|
22
|
+
* task-328 fixed (greedy tail-strip ate trailing prose).
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
import { loadSession } from './session.js';
|