@yeaft/webchat-agent 0.1.510 → 0.1.512
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 +64 -11
- 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 +150 -3
- package/package.json +1 -1
- package/unify/engine.js +55 -4
- package/unify/memory/layout.js +1 -1
- package/unify/prompts.js +40 -5
- package/unify/tools/index.js +5 -5
- package/unify/tools/js-repl.js +42 -15
- package/unify/tools/memory-query.js +3 -1
- package/unify/tools/memory-search.js +102 -63
- package/unify/tools/task-tools.js +22 -5
- package/unify/tools/thread-tools.js +16 -11
- package/unify/tools/view-image.js +249 -50
- package/unify/web-bridge.js +9 -0
- package/unify/tools/tool-search.js +0 -88
- package/unify/tools/write-stdin.js +0 -53
|
@@ -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 读取)
|
|
@@ -160,10 +162,29 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
160
162
|
const parseResult = parseRoutes(roleState.accumulatedText);
|
|
161
163
|
const routes = parseResult;
|
|
162
164
|
const displayBody = parseResult.displayBody || roleState.accumulatedText;
|
|
163
|
-
//
|
|
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.
|
|
164
185
|
for (const route of routes) {
|
|
165
|
-
if (route.summary === '[该角色未提供消息摘要]' &&
|
|
166
|
-
const tail =
|
|
186
|
+
if (route.summary === '[该角色未提供消息摘要]' && displayBody) {
|
|
187
|
+
const tail = displayBody.slice(-500).trim();
|
|
167
188
|
if (tail) route.summary = `[auto-extracted]\n${tail}`;
|
|
168
189
|
}
|
|
169
190
|
}
|
|
@@ -265,27 +286,59 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
265
286
|
});
|
|
266
287
|
sendStatusUpdate(session);
|
|
267
288
|
} else {
|
|
268
|
-
// ★ No ROUTE found —
|
|
269
|
-
|
|
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)
|
|
270
294
|
const hasActiveTask = !!roleState.currentTask;
|
|
271
295
|
const hasRouteIntent = _detectRouteIntent(roleState.lastTurnText);
|
|
296
|
+
const fallbackTo = resolveFallbackTarget(session, roleName, 'missing-route', {
|
|
297
|
+
hasActiveTask,
|
|
298
|
+
hasRouteIntent,
|
|
299
|
+
});
|
|
272
300
|
|
|
273
|
-
if (
|
|
274
|
-
//
|
|
275
|
-
|
|
301
|
+
if (fallbackTo) {
|
|
302
|
+
// §B item 1 — record the metric on every fallback dispatch.
|
|
303
|
+
const reason = hasActiveTask ? 'has active task' : 'has routing intent';
|
|
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
|
+
});
|
|
276
317
|
// task-328: forward parser-clean displayBody (ROUTE residue removed)
|
|
277
318
|
// so PM sees the actual prose, not stray END markers.
|
|
278
|
-
|
|
279
|
-
console.log(`[Crew] ${roleName} turn ended without ROUTE (${reason}) — auto-forwarding to PM`);
|
|
319
|
+
console.log(`[Crew] ${roleName} turn ended without ROUTE (${reason}) — auto-forwarding to ${fallbackTo}`);
|
|
280
320
|
const forwardSource = roleState.lastTurnDisplayBody || roleState.lastTurnText || '';
|
|
281
321
|
const autoSummary = `[auto-forward: ${roleName} turn 结束但未输出 ROUTE 块 (${reason})]\n${forwardSource.slice(-800).trim()}`;
|
|
282
322
|
await executeRoute(session, roleName, {
|
|
283
|
-
to:
|
|
323
|
+
to: fallbackTo,
|
|
284
324
|
summary: autoSummary,
|
|
285
325
|
taskId: roleState.currentTask?.taskId || null,
|
|
286
326
|
taskTitle: roleState.currentTask?.taskTitle || null,
|
|
287
327
|
});
|
|
288
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
|
+
}
|
|
289
342
|
const { processHumanQueue } = await import('./human-interaction.js');
|
|
290
343
|
await processHumanQueue(session);
|
|
291
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
|
+
}
|