@khorsheed/dsh-room 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.i18n.yaml +6 -0
- package/README.md +48 -0
- package/README.zh.md +48 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +11481 -0
- package/lib/index.js +3231 -0
- package/lib/invariant.js +26 -0
- package/lib/journal-CuRnWk66.js +435 -0
- package/lib/tool.js +350 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +2615 -0
- package/lib/typert.remote-client.d.ts +55 -0
- package/lib/typert.remote-client.js +1711 -0
- package/lib/types/adapter.d.ts +42 -0
- package/lib/types/adapter.js +41 -0
- package/lib/types/agent-setup.d.ts +141 -0
- package/lib/types/agent-setup.js +145 -0
- package/lib/types/client/InviteAgentAction.d.ts +13 -0
- package/lib/types/client/InviteAgentAction.js +45 -0
- package/lib/types/client/InviteDialog.d.ts +90 -0
- package/lib/types/client/InviteDialog.js +216 -0
- package/lib/types/client/MemberCard.d.ts +33 -0
- package/lib/types/client/MemberCard.js +32 -0
- package/lib/types/client/MembersView.d.ts +5 -0
- package/lib/types/client/MembersView.js +168 -0
- package/lib/types/client/RoomComposer.d.ts +21 -0
- package/lib/types/client/RoomComposer.js +211 -0
- package/lib/types/client/RoomDockCapsules.d.ts +64 -0
- package/lib/types/client/RoomDockCapsules.js +277 -0
- package/lib/types/client/RoomEventView.d.ts +11 -0
- package/lib/types/client/RoomEventView.js +17 -0
- package/lib/types/client/RoomModelPicker.d.ts +18 -0
- package/lib/types/client/RoomModelPicker.js +134 -0
- package/lib/types/client/RoomPlanView.d.ts +17 -0
- package/lib/types/client/RoomPlanView.js +88 -0
- package/lib/types/client/RoomQueueStrip.d.ts +26 -0
- package/lib/types/client/RoomQueueStrip.js +28 -0
- package/lib/types/client/RoomRecoveryView.d.ts +11 -0
- package/lib/types/client/RoomRecoveryView.js +38 -0
- package/lib/types/client/RoomRelayView.d.ts +12 -0
- package/lib/types/client/RoomRelayView.js +20 -0
- package/lib/types/client/RoomRunView.d.ts +15 -0
- package/lib/types/client/RoomRunView.js +50 -0
- package/lib/types/client/RoomSpeechView.d.ts +18 -0
- package/lib/types/client/RoomSpeechView.js +62 -0
- package/lib/types/client/RoomStatsLine.d.ts +61 -0
- package/lib/types/client/RoomStatsLine.js +138 -0
- package/lib/types/client/RoomTaskLineView.d.ts +13 -0
- package/lib/types/client/RoomTaskLineView.js +21 -0
- package/lib/types/client/RoomTodoStrip.d.ts +24 -0
- package/lib/types/client/RoomTodoStrip.js +64 -0
- package/lib/types/client/format.d.ts +31 -0
- package/lib/types/client/format.js +48 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +39 -0
- package/lib/types/client/index.js +471 -0
- package/lib/types/client/locales.d.ts +422 -0
- package/lib/types/client/locales.js +413 -0
- package/lib/types/client/member-color.d.ts +13 -0
- package/lib/types/client/member-color.js +22 -0
- package/lib/types/client/name-pool.d.ts +17 -0
- package/lib/types/client/name-pool.js +25 -0
- package/lib/types/client/nodes.d.ts +136 -0
- package/lib/types/client/nodes.js +212 -0
- package/lib/types/client/preset-visibility.d.ts +91 -0
- package/lib/types/client/preset-visibility.js +113 -0
- package/lib/types/client/request-ids.d.ts +7 -0
- package/lib/types/client/request-ids.js +21 -0
- package/lib/types/client/room-store.d.ts +137 -0
- package/lib/types/client/room-store.js +244 -0
- package/lib/types/client/slots.d.ts +319 -0
- package/lib/types/client/slots.js +1 -0
- package/lib/types/deliveries.d.ts +5 -0
- package/lib/types/deliveries.js +20 -0
- package/lib/types/dispatch.d.ts +105 -0
- package/lib/types/dispatch.js +467 -0
- package/lib/types/index.d.ts +301 -0
- package/lib/types/index.js +1200 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +24 -0
- package/lib/types/journal.d.ts +117 -0
- package/lib/types/journal.js +434 -0
- package/lib/types/plan-service.d.ts +34 -0
- package/lib/types/plan-service.js +266 -0
- package/lib/types/plan.d.ts +168 -0
- package/lib/types/plan.js +494 -0
- package/lib/types/tool.d.ts +56 -0
- package/lib/types/tool.js +389 -0
- package/lib/types/types.d.ts +813 -0
- package/lib/types/types.js +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +131 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { Session } from '@deepseek-ai/dsh-session';
|
|
3
|
+
import { type ContextFormed, type MessageSource } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import type { RoomState } from './types.ts';
|
|
5
|
+
/**
|
|
6
|
+
* The room producer's durable identity in its two historical forms: the
|
|
7
|
+
* released V3 wrapper's `plugin` value (`@khorsheed/dsh-room` — the official
|
|
8
|
+
* V3→V4 migration rewrites it to `kind: 'plugin:@khorsheed/dsh-room'`), and
|
|
9
|
+
* the current producer-owned kind (`room`). {@link isRoomSource} accepts all
|
|
10
|
+
* three forms; new writes always carry the producer kind.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ROOM_PLUGIN = "@khorsheed/dsh-room";
|
|
13
|
+
/** The producer-owned source kind identifying every room-dispatch followup. */
|
|
14
|
+
export declare const ROOM_KIND = "room";
|
|
15
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
16
|
+
interface MessageSourceMap {
|
|
17
|
+
/** room producer: the main-agent member turn's followup prompt. */
|
|
18
|
+
room: {
|
|
19
|
+
kind: 'room';
|
|
20
|
+
} & ContextFormed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The producer-owned source of a room main-agent followup. */
|
|
24
|
+
export declare function roomSource(): MessageSource;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a durable source belongs to the room producer, in every form the
|
|
27
|
+
* log can hold: the current producer-owned kind (`room`), the V3→V4 migrated
|
|
28
|
+
* kind (`plugin:@khorsheed/dsh-room`), and the released V3 wrapper a pre-V4
|
|
29
|
+
* (0.1.5) host still serves verbatim.
|
|
30
|
+
* @param source - the persisted message source to test.
|
|
31
|
+
* @returns true when the source names this producer.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isRoomSource(source: unknown): boolean;
|
|
34
|
+
/** Extra dispatch context beyond the text. */
|
|
35
|
+
export interface DispatchOptions {
|
|
36
|
+
/**
|
|
37
|
+
* The appended `room/dispatch` event's seq. Omitted for a dispatch that is
|
|
38
|
+
* not itself journaled (a relay delivery): the instruction cursor then
|
|
39
|
+
* reads against the whole log.
|
|
40
|
+
*/
|
|
41
|
+
readonly dispatchSeq?: number;
|
|
42
|
+
readonly targetId?: string;
|
|
43
|
+
/** Relays this dispatch DELIVERS (confirmRelay's own payloads). */
|
|
44
|
+
readonly relayIds?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
export interface RunOutcome {
|
|
47
|
+
readonly state: 'done' | 'cancelled' | 'failed';
|
|
48
|
+
readonly text?: string;
|
|
49
|
+
readonly error?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface DispatchHooks {
|
|
52
|
+
allows?(room: Session, dispatchSeq?: number): boolean;
|
|
53
|
+
admitted?(room: Session, dispatchSeq?: number): Promise<void>;
|
|
54
|
+
settled?(room: Session, dispatchSeq: number | undefined, outcome: RunOutcome): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The per-room dispatch engine. Owned by the RoomService; all state lives in
|
|
58
|
+
* the journal except the in-process FIFO queues (a restart simply has no
|
|
59
|
+
* in-flight runs).
|
|
60
|
+
*/
|
|
61
|
+
export declare class DispatchEngine {
|
|
62
|
+
private readonly ctx;
|
|
63
|
+
private readonly hooks;
|
|
64
|
+
/** memberKey → the queue tail promise. */
|
|
65
|
+
private readonly queues;
|
|
66
|
+
private readonly scheduled;
|
|
67
|
+
private readonly inFlight;
|
|
68
|
+
/**
|
|
69
|
+
* @param ctx - host context carrying the session store and agents registry.
|
|
70
|
+
*/
|
|
71
|
+
constructor(ctx: Context, hooks?: DispatchHooks);
|
|
72
|
+
/**
|
|
73
|
+
* Enqueue a dispatch for execution. Fire-and-forget: the outcome is
|
|
74
|
+
* journaled as run-state/speech events, never returned to the caller.
|
|
75
|
+
* @param room - the room session.
|
|
76
|
+
* @param memberName - the dispatch target.
|
|
77
|
+
* @param text - the dispatch text.
|
|
78
|
+
* @param options - dispatch-event seq and/or the relays this run delivers.
|
|
79
|
+
*/
|
|
80
|
+
hasPending(room: Session, memberName: string): boolean;
|
|
81
|
+
dispatch(room: Session, memberName: string, text: string, options?: DispatchOptions): void;
|
|
82
|
+
/** Drain includes reports queued by the runs being drained. */
|
|
83
|
+
idle(): Promise<void>;
|
|
84
|
+
/** Read-only recovery projection: persisted running edges are not live processes. */
|
|
85
|
+
view(sessionId: string, state: RoomState): RoomState;
|
|
86
|
+
/** Restore accepted targets, never repeat an execution whose settlement is unknown. */
|
|
87
|
+
recover(room: Session): Promise<void>;
|
|
88
|
+
/** Execute one dispatch: run-state running → member turn → settle edges. */
|
|
89
|
+
private run;
|
|
90
|
+
private report;
|
|
91
|
+
/**
|
|
92
|
+
* Mark the relays this run delivered as sent. Delivery = the member's own
|
|
93
|
+
* session received the prompt (the followup accepted / the CLI run
|
|
94
|
+
* started): the text lives in the member's private context from then on,
|
|
95
|
+
* so a later run failure cannot un-deliver it.
|
|
96
|
+
*/
|
|
97
|
+
private markSent;
|
|
98
|
+
/** Main-agent member turn: a producer-sourced followup on the room's own agent. */
|
|
99
|
+
private runMainAgent;
|
|
100
|
+
/** CLI member turn: facade start (first round) or resume, then the speech mirror. */
|
|
101
|
+
private runCliMember;
|
|
102
|
+
/** Settle only this execution and its own task rows, preserving newer runs. */
|
|
103
|
+
private settle;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=dispatch.d.ts.map
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch engine: turns `room/dispatch` journal records and confirmed
|
|
3
|
+
* notification relays into member runs. Same-member dispatches serialize on
|
|
4
|
+
* a submission chain until a stable native identity exists, then enter the
|
|
5
|
+
* family's whole-turn FIFO; different members run in parallel. Every lifecycle
|
|
6
|
+
* edge lands in the journal (`room/run-state` running → done/cancelled/
|
|
7
|
+
* failed, plus `room/speech` for a CLI member's reply), so a reload replays
|
|
8
|
+
* the exact dispatch history — but speech/dispatch events are UI projection
|
|
9
|
+
* and replay ONLY: no prompt ever consumes the room's running log.
|
|
10
|
+
*
|
|
11
|
+
* The prompt every member receives is uniform (the design note's roster +
|
|
12
|
+
* notifications contract): role instructions (first dispatch, or an edit
|
|
13
|
+
* carried as an update) + the room goal (one line, every dispatch — cheap,
|
|
14
|
+
* and it orients the task) + the roster (who exists, with the notification
|
|
15
|
+
* protocol) ONLY when the member has never seen it or it changed since their
|
|
16
|
+
* last dispatch ({@link rosterStaleSince}) + the member's
|
|
17
|
+
* confirmed-undelivered notifications + this dispatch's text. The member's
|
|
18
|
+
* own CLI session (resume chain) holds its working memory; room never
|
|
19
|
+
* resends what it has seen.
|
|
20
|
+
* @module @khorsheed/dsh-room/dispatch
|
|
21
|
+
*/
|
|
22
|
+
import { replayDeliveries } from './deliveries.js';
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
25
|
+
import { probeLocalAgent, runOutputText } from './adapter.js';
|
|
26
|
+
import { coordinatorMember, memberId, parseRelayDirective, pendingInstructions, previousCursor, replay, rosterStaleSince } from './journal.js';
|
|
27
|
+
/**
|
|
28
|
+
* The room producer's durable identity in its two historical forms: the
|
|
29
|
+
* released V3 wrapper's `plugin` value (`@khorsheed/dsh-room` — the official
|
|
30
|
+
* V3→V4 migration rewrites it to `kind: 'plugin:@khorsheed/dsh-room'`), and
|
|
31
|
+
* the current producer-owned kind (`room`). {@link isRoomSource} accepts all
|
|
32
|
+
* three forms; new writes always carry the producer kind.
|
|
33
|
+
*/
|
|
34
|
+
export const ROOM_PLUGIN = '@khorsheed/dsh-room';
|
|
35
|
+
/** The producer-owned source kind identifying every room-dispatch followup. */
|
|
36
|
+
export const ROOM_KIND = 'room';
|
|
37
|
+
/** The producer-owned source of a room main-agent followup. */
|
|
38
|
+
export function roomSource() {
|
|
39
|
+
return Object.freeze({ kind: ROOM_KIND });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether a durable source belongs to the room producer, in every form the
|
|
43
|
+
* log can hold: the current producer-owned kind (`room`), the V3→V4 migrated
|
|
44
|
+
* kind (`plugin:@khorsheed/dsh-room`), and the released V3 wrapper a pre-V4
|
|
45
|
+
* (0.1.5) host still serves verbatim.
|
|
46
|
+
* @param source - the persisted message source to test.
|
|
47
|
+
* @returns true when the source names this producer.
|
|
48
|
+
*/
|
|
49
|
+
export function isRoomSource(source) {
|
|
50
|
+
if (typeof source !== 'object' || source === null)
|
|
51
|
+
return false;
|
|
52
|
+
const record = source;
|
|
53
|
+
return record.kind === ROOM_KIND
|
|
54
|
+
|| record.kind === `plugin:${ROOM_PLUGIN}`
|
|
55
|
+
|| (record.kind === 'plugin' && record.plugin === ROOM_PLUGIN);
|
|
56
|
+
}
|
|
57
|
+
function closedGoal(state, goalId) {
|
|
58
|
+
return goalId !== undefined && state.plan !== undefined
|
|
59
|
+
&& (state.plan.id !== goalId || ['completed', 'cancelled'].includes(state.plan.status));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The roster section: one line per OTHER member (name, provider, one-line
|
|
63
|
+
* role) plus the notification protocol — the fallback channel's trailing
|
|
64
|
+
* own-line `@name <content>` format lives here so a member can reach others
|
|
65
|
+
* without any bridge tooling. Carried only when the member has never seen
|
|
66
|
+
* the roster or it changed since their last dispatch ({@link rosterStaleSince});
|
|
67
|
+
* the member's own session holds it in between. The room GOAL is NOT part of
|
|
68
|
+
* this section: it rides every dispatch on its own line (cheap, and it
|
|
69
|
+
* orients the task).
|
|
70
|
+
*/
|
|
71
|
+
function rosterSection(state, self) {
|
|
72
|
+
const lines = state.members
|
|
73
|
+
.filter(member => member.name !== self)
|
|
74
|
+
.map((member) => {
|
|
75
|
+
const provider = member.kind === 'main-agent' ? '原生 DSH' : member.provider ?? '';
|
|
76
|
+
const role = member.instructions?.split('\n', 1)[0]?.trim();
|
|
77
|
+
return `- ${member.name}(${provider})${role === undefined || role === '' ? '' : `:${role}`}`;
|
|
78
|
+
});
|
|
79
|
+
return [
|
|
80
|
+
'【成员名册】',
|
|
81
|
+
...lines,
|
|
82
|
+
'通知协议:要通知某个成员,在回复末尾独占一行写 `@名字 <内容>`;该行会被转交给对方(一阶段需房间主人确认)。',
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
/** The notifications section: confirmed-undelivered relays addressed to the member. */
|
|
86
|
+
function notificationsSection(relays) {
|
|
87
|
+
return ['【通知】', ...relays.map(relay => `- ${relay.from} 给你的通知: ${relay.content}`)].join('\n');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Assemble the uniform member prompt: role-instructions carry, then the room
|
|
91
|
+
* goal (every dispatch), then the roster (only when stale for this member),
|
|
92
|
+
* then the member's confirmed-undelivered notifications (except the ones
|
|
93
|
+
* this dispatch's own text already delivers), then this dispatch's text.
|
|
94
|
+
*/
|
|
95
|
+
function assemblePrompt(room, member, cursor, text, relayIds) {
|
|
96
|
+
const state = replay(room.snapshotEvents());
|
|
97
|
+
const carried = state.relays.filter(relay => relay.state === 'confirmed' && relay.to === member.name && !relayIds.includes(relay.id));
|
|
98
|
+
const sections = [];
|
|
99
|
+
const handoff = room.snapshotEvents().filter(event => event.type === 'room/coordinator' && event.data.memberId === memberId(room.snapshotEvents(), member)).at(-1);
|
|
100
|
+
if (handoff?.type === 'room/coordinator' && (cursor === undefined || handoff.seq > cursor))
|
|
101
|
+
sections.push(handoff.data.handoff);
|
|
102
|
+
if (coordinatorMember(state, room.snapshotEvents())?.name === member.name)
|
|
103
|
+
sections.push('You are the room coordinator. Handle ordinary chat directly; delegate small work in the background using room tools. Establish a goal plan only when useful for the requested execution. Delegate results arrive as correlated reports; do not poll while waiting.');
|
|
104
|
+
const pending = pendingInstructions(room.snapshotEvents(), member.name, cursor);
|
|
105
|
+
if (pending?.kind === 'initial')
|
|
106
|
+
sections.push(`你的角色指令:${pending.instructions}`);
|
|
107
|
+
if (pending?.kind === 'update')
|
|
108
|
+
sections.push(`你的角色指令更新为:${pending.instructions}`);
|
|
109
|
+
if (state.goal !== undefined)
|
|
110
|
+
sections.push(`本房间的目标:${state.goal}`);
|
|
111
|
+
if (rosterStaleSince(room.snapshotEvents(), cursor))
|
|
112
|
+
sections.push(rosterSection(state, member.name));
|
|
113
|
+
if (carried.length > 0)
|
|
114
|
+
sections.push(notificationsSection(carried));
|
|
115
|
+
sections.push(text);
|
|
116
|
+
return { prompt: sections.join('\n\n'), carried };
|
|
117
|
+
}
|
|
118
|
+
/** The human-readable reason a run failed (Error message, else String()). */
|
|
119
|
+
function faultMessage(error) {
|
|
120
|
+
return error instanceof Error ? error.message : String(error);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The per-room dispatch engine. Owned by the RoomService; all state lives in
|
|
124
|
+
* the journal except the in-process FIFO queues (a restart simply has no
|
|
125
|
+
* in-flight runs).
|
|
126
|
+
*/
|
|
127
|
+
export class DispatchEngine {
|
|
128
|
+
ctx;
|
|
129
|
+
hooks;
|
|
130
|
+
/** memberKey → the queue tail promise. */
|
|
131
|
+
queues = new Map();
|
|
132
|
+
scheduled = new Set();
|
|
133
|
+
inFlight = new Map();
|
|
134
|
+
/**
|
|
135
|
+
* @param ctx - host context carrying the session store and agents registry.
|
|
136
|
+
*/
|
|
137
|
+
constructor(ctx, hooks = {}) {
|
|
138
|
+
this.ctx = ctx;
|
|
139
|
+
this.hooks = hooks;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Enqueue a dispatch for execution. Fire-and-forget: the outcome is
|
|
143
|
+
* journaled as run-state/speech events, never returned to the caller.
|
|
144
|
+
* @param room - the room session.
|
|
145
|
+
* @param memberName - the dispatch target.
|
|
146
|
+
* @param text - the dispatch text.
|
|
147
|
+
* @param options - dispatch-event seq and/or the relays this run delivers.
|
|
148
|
+
*/
|
|
149
|
+
hasPending(room, memberName) {
|
|
150
|
+
const member = replay(room.snapshotEvents()).members.find(member => member.name === memberName);
|
|
151
|
+
return member !== undefined && ([...this.inFlight.values()].includes(`${room.id} ${memberId(room.snapshotEvents(), member)}`)
|
|
152
|
+
|| replayDeliveries(room.snapshotEvents()).some(delivery => delivery.memberId === member.id && ['queued', 'running', 'uncertain'].includes(delivery.status)));
|
|
153
|
+
}
|
|
154
|
+
dispatch(room, memberName, text, options = {}) {
|
|
155
|
+
const events = room.snapshotEvents();
|
|
156
|
+
const member = replay(events).members.find(entry => entry.name === memberName);
|
|
157
|
+
if (member === undefined)
|
|
158
|
+
return;
|
|
159
|
+
const targetId = options.targetId ?? memberId(events, member);
|
|
160
|
+
const key = `${room.id} ${targetId}`;
|
|
161
|
+
const deliveryId = options.dispatchSeq === undefined ? undefined : `${options.dispatchSeq}:${targetId}`;
|
|
162
|
+
if (deliveryId !== undefined) {
|
|
163
|
+
const scopedId = `${room.id}:${deliveryId}`;
|
|
164
|
+
if (this.scheduled.has(scopedId) || events.some(event => event.type === 'room/delivery-state' && event.data.id === deliveryId))
|
|
165
|
+
return;
|
|
166
|
+
this.scheduled.add(scopedId);
|
|
167
|
+
}
|
|
168
|
+
const tail = this.queues.get(key) ?? Promise.resolve();
|
|
169
|
+
let submitted;
|
|
170
|
+
const submission = new Promise(resolve => { submitted = resolve; });
|
|
171
|
+
const next = tail.then(() => this.run(room, memberName, text, { ...options, targetId }, submitted))
|
|
172
|
+
.catch((error) => { this.ctx.logger.warn(`room: dispatch to "${memberName}" faulted: ${String(error)}`); })
|
|
173
|
+
.finally(() => {
|
|
174
|
+
submitted();
|
|
175
|
+
this.inFlight.delete(next);
|
|
176
|
+
if (deliveryId !== undefined)
|
|
177
|
+
this.scheduled.delete(`${room.id}:${deliveryId}`);
|
|
178
|
+
});
|
|
179
|
+
this.inFlight.set(next, key);
|
|
180
|
+
this.queues.set(key, submission);
|
|
181
|
+
void submission.then(() => { if (this.queues.get(key) === submission)
|
|
182
|
+
this.queues.delete(key); });
|
|
183
|
+
}
|
|
184
|
+
/** Drain includes reports queued by the runs being drained. */
|
|
185
|
+
async idle() {
|
|
186
|
+
while (this.inFlight.size > 0)
|
|
187
|
+
await Promise.all([...this.inFlight.keys()]);
|
|
188
|
+
}
|
|
189
|
+
/** Read-only recovery projection: persisted running edges are not live processes. */
|
|
190
|
+
view(sessionId, state) {
|
|
191
|
+
const error = 'Execution outcome is unknown after restart; reconcile before retrying';
|
|
192
|
+
return {
|
|
193
|
+
...state,
|
|
194
|
+
...state.deliveries === undefined ? {} : { deliveries: state.deliveries.map(row => row.status === 'queued' && closedGoal(state, row.plan?.goalId)
|
|
195
|
+
? { ...row, status: 'cancelled' } : row.status === 'running' && !this.scheduled.has(`${sessionId}:${row.id}`)
|
|
196
|
+
? { ...row, status: 'uncertain', error } : row) },
|
|
197
|
+
runs: state.runs.map(run => {
|
|
198
|
+
const member = state.members.find(row => row.name === run.member);
|
|
199
|
+
const active = run.runId === undefined
|
|
200
|
+
? member !== undefined && [...this.inFlight.values()].includes(`${sessionId} ${member.id}`)
|
|
201
|
+
: this.scheduled.has(`${sessionId}:${run.runId}`);
|
|
202
|
+
return run.state === 'running' && !active ? { ...run, state: 'failed', error } : run;
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Restore accepted targets, never repeat an execution whose settlement is unknown. */
|
|
207
|
+
async recover(room) {
|
|
208
|
+
const events = room.snapshotEvents();
|
|
209
|
+
const state = replay(events);
|
|
210
|
+
for (const dispatch of events) {
|
|
211
|
+
if (dispatch.type !== 'room/dispatch' || dispatch.data.targetIds === undefined)
|
|
212
|
+
continue;
|
|
213
|
+
for (const targetId of dispatch.data.targetIds) {
|
|
214
|
+
const id = `${dispatch.seq}:${targetId}`;
|
|
215
|
+
if (this.scheduled.has(`${room.id}:${id}`))
|
|
216
|
+
continue;
|
|
217
|
+
const edges = events.filter(event => event.type === 'room/delivery-state' && event.data.id === id);
|
|
218
|
+
const last = edges.at(-1);
|
|
219
|
+
if (last === undefined && closedGoal(state, dispatch.data.plan?.goalId)) {
|
|
220
|
+
room.append('room/delivery-state', { id, dispatchSeq: dispatch.seq, memberId: targetId, state: 'cancelled', error: 'Goal closed before delivery admission' });
|
|
221
|
+
await this.ctx.sessions.flush(room);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (last?.type === 'room/delivery-state') {
|
|
225
|
+
if (last.data.state === 'running') {
|
|
226
|
+
room.append('room/delivery-state', { id, dispatchSeq: dispatch.seq, memberId: targetId, state: 'uncertain', error: 'Host restarted before a durable settlement; reconcile before retrying' });
|
|
227
|
+
const target = state.members.find(member => memberId(events, member) === targetId);
|
|
228
|
+
if (target !== undefined) {
|
|
229
|
+
const run = state.runs.find(run => run.member === target.name);
|
|
230
|
+
if (run?.state === 'running' && (run.runId === undefined || run.runId === id))
|
|
231
|
+
room.append('room/run-state', { member: target.name, state: 'failed', startedAt: run.startedAt, ...run.runId === undefined ? {} : { runId: run.runId }, error: 'Execution outcome is unknown after restart; reconcile before retrying' });
|
|
232
|
+
}
|
|
233
|
+
await this.ctx.sessions.flush(room);
|
|
234
|
+
await this.report(room, dispatch.seq, id, targetId, 'uncertain', 'Execution outcome is unknown after restart; reconcile before retrying');
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
await this.report(room, dispatch.seq, id, targetId, last.data.state, last.data.text ?? last.data.error ?? '');
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const target = state.members.find(member => memberId(events, member) === targetId);
|
|
242
|
+
if (target !== undefined && !state.deliveries?.some(row => row.memberId === targetId && (row.status === 'running' || row.status === 'uncertain')))
|
|
243
|
+
this.dispatch(room, target.name, dispatch.data.text, { dispatchSeq: dispatch.seq, targetId });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Execute one dispatch: run-state running → member turn → settle edges. */
|
|
248
|
+
async run(room, memberName, text, options, submitted) {
|
|
249
|
+
if (this.hooks.allows?.(room, options.dispatchSeq) === false)
|
|
250
|
+
return;
|
|
251
|
+
const state = replay(room.snapshotEvents());
|
|
252
|
+
const member = state.members.find(entry => options.targetId === undefined ? entry.name === memberName : memberId(room.snapshotEvents(), entry) === options.targetId);
|
|
253
|
+
// Removed between the dispatch and its execution: nothing to run, and the
|
|
254
|
+
// removal event is already the journal's answer.
|
|
255
|
+
if (member === undefined)
|
|
256
|
+
return;
|
|
257
|
+
if (state.deliveries?.some(delivery => delivery.memberId === member.id && delivery.status === 'uncertain'))
|
|
258
|
+
return;
|
|
259
|
+
memberName = member.name;
|
|
260
|
+
const identity = memberId(room.snapshotEvents(), member);
|
|
261
|
+
const deliveryId = options.dispatchSeq === undefined ? undefined : `${options.dispatchSeq}:${identity}`;
|
|
262
|
+
const runId = deliveryId ?? randomUUID();
|
|
263
|
+
const cursor = previousCursor(room.snapshotEvents(), memberName, options.dispatchSeq ?? Number.MAX_SAFE_INTEGER);
|
|
264
|
+
let startedAt;
|
|
265
|
+
const admitted = async () => {
|
|
266
|
+
if (startedAt !== undefined)
|
|
267
|
+
throw new Error('Room delivery was admitted twice');
|
|
268
|
+
startedAt = Date.now();
|
|
269
|
+
if (deliveryId !== undefined)
|
|
270
|
+
room.append('room/delivery-state', { id: deliveryId, dispatchSeq: options.dispatchSeq, memberId: identity, state: 'running' });
|
|
271
|
+
memberName = replay(room.snapshotEvents()).members.find(entry => memberId(room.snapshotEvents(), entry) === identity)?.name ?? memberName;
|
|
272
|
+
room.append('room/run-state', { member: memberName, state: 'running', startedAt, runId });
|
|
273
|
+
await this.hooks.admitted?.(room, options.dispatchSeq);
|
|
274
|
+
await this.ctx.sessions.flush(room);
|
|
275
|
+
return startedAt;
|
|
276
|
+
};
|
|
277
|
+
let outcome;
|
|
278
|
+
try {
|
|
279
|
+
outcome = member.kind === 'main-agent'
|
|
280
|
+
? await this.runMainAgent(room, member, cursor, text, await admitted(), options.relayIds ?? [])
|
|
281
|
+
: await this.runCliMember(room, member, cursor, text, admitted, options.relayIds ?? [], submitted);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
this.ctx.logger.warn(`room: dispatch to "${memberName}" failed: ${String(error)}`);
|
|
285
|
+
outcome = { state: 'failed', error: faultMessage(error) };
|
|
286
|
+
}
|
|
287
|
+
outcome = await this.settle(room, member, runId, startedAt ?? Date.now(), outcome);
|
|
288
|
+
await this.hooks.settled?.(room, options.dispatchSeq, outcome);
|
|
289
|
+
if (deliveryId !== undefined) {
|
|
290
|
+
room.append('room/delivery-state', { id: deliveryId, dispatchSeq: options.dispatchSeq, memberId: identity, state: outcome.state,
|
|
291
|
+
...outcome.text === undefined ? {} : { text: outcome.text }, ...outcome.error === undefined ? {} : { error: outcome.error } });
|
|
292
|
+
await this.ctx.sessions.flush(room);
|
|
293
|
+
await this.report(room, options.dispatchSeq, deliveryId, identity, outcome.state, outcome.text ?? outcome.error ?? '');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async report(room, dispatchSeq, deliveryId, fromId, outcome, text) {
|
|
297
|
+
const events = room.snapshotEvents();
|
|
298
|
+
const source = events.find(event => event.seq === dispatchSeq);
|
|
299
|
+
if (source?.type !== 'room/dispatch' || source.data.origin !== 'coordinator' || source.data.replyTo === undefined)
|
|
300
|
+
return;
|
|
301
|
+
if (events.some(event => event.type === 'room/dispatch' && event.data.reportFor === deliveryId))
|
|
302
|
+
return;
|
|
303
|
+
const state = replay(events);
|
|
304
|
+
if (closedGoal(state, source.data.plan?.goalId))
|
|
305
|
+
return;
|
|
306
|
+
const recipient = source.data.plan === undefined ? state.members.find(member => memberId(events, member) === source.data.replyTo) : coordinatorMember(state, events);
|
|
307
|
+
const sender = state.members.find(member => memberId(events, member) === fromId);
|
|
308
|
+
if (recipient === undefined)
|
|
309
|
+
return;
|
|
310
|
+
const reportText = `Delegation ${deliveryId} (${sender?.name ?? fromId}) ${outcome}.\n${text.slice(0, 32000)}`;
|
|
311
|
+
const report = room.append('room/dispatch', { id: randomUUID(), origin: 'report', reportFor: deliveryId,
|
|
312
|
+
targets: [recipient.name], targetIds: [memberId(events, recipient)], text: reportText,
|
|
313
|
+
...source.data.plan === undefined ? {} : { plan: { goalId: source.data.plan.goalId } } });
|
|
314
|
+
await this.ctx.sessions.flush(room);
|
|
315
|
+
if (this.hooks.allows?.(room, report.seq) !== false)
|
|
316
|
+
this.dispatch(room, recipient.name, reportText, { dispatchSeq: report.seq, targetId: memberId(events, recipient) });
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Mark the relays this run delivered as sent. Delivery = the member's own
|
|
320
|
+
* session received the prompt (the followup accepted / the CLI run
|
|
321
|
+
* started): the text lives in the member's private context from then on,
|
|
322
|
+
* so a later run failure cannot un-deliver it.
|
|
323
|
+
*/
|
|
324
|
+
async markSent(room, relays) {
|
|
325
|
+
if (relays.length === 0)
|
|
326
|
+
return;
|
|
327
|
+
for (const relay of relays)
|
|
328
|
+
room.append('room/relay-resolved', { id: relay.id, state: 'sent' });
|
|
329
|
+
await this.ctx.sessions.flush(room);
|
|
330
|
+
}
|
|
331
|
+
/** Main-agent member turn: a producer-sourced followup on the room's own agent. */
|
|
332
|
+
async runMainAgent(room, member, cursor, text, _startedAt, relayIds) {
|
|
333
|
+
const agent = this.ctx.agents.get(room.id);
|
|
334
|
+
if (agent === undefined) {
|
|
335
|
+
// Fail loud: the room's own agent is not live, the message goes nowhere.
|
|
336
|
+
return { state: 'failed', error: 'the room session has no live agent' };
|
|
337
|
+
}
|
|
338
|
+
const { prompt, carried } = assemblePrompt(room, member, cursor, text, relayIds);
|
|
339
|
+
agent.followup(createUserMessage({
|
|
340
|
+
content: [{ type: 'text', text: prompt }],
|
|
341
|
+
source: roomSource(),
|
|
342
|
+
}));
|
|
343
|
+
await this.markSent(room, [
|
|
344
|
+
...carried,
|
|
345
|
+
...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
|
|
346
|
+
]);
|
|
347
|
+
// Phase-1 simplicity: the followup's own turn converging to idle IS the
|
|
348
|
+
// run; the main agent's reply is its ordinary assistant speech (no
|
|
349
|
+
// room/speech projection — speech mirrors CLI members only).
|
|
350
|
+
await agent.whenIdle();
|
|
351
|
+
return { state: 'done' };
|
|
352
|
+
}
|
|
353
|
+
/** CLI member turn: facade start (first round) or resume, then the speech mirror. */
|
|
354
|
+
async runCliMember(room, member, cursor, text, admitted, relayIds, submitted) {
|
|
355
|
+
const facade = probeLocalAgent(this.ctx);
|
|
356
|
+
if (facade === undefined) {
|
|
357
|
+
this.ctx.logger.warn(`room: cannot dispatch to "${member.name}": local-agent facade unavailable`);
|
|
358
|
+
throw new Error('the local-agent delegation facade is unavailable');
|
|
359
|
+
}
|
|
360
|
+
// Roster invariant: cli members always carry a provider (invite enforces).
|
|
361
|
+
const provider = member.provider ?? '';
|
|
362
|
+
// Modern providers call this only after the whole-turn FIFO and model
|
|
363
|
+
// configuration admit the request. Persistence must finish before native work.
|
|
364
|
+
let startedAt;
|
|
365
|
+
const controlled = facade.supportsMemberConfiguration?.(provider) === true;
|
|
366
|
+
const admission = controlled ? { onAdmitted: async () => { startedAt = await admitted(); } } : {};
|
|
367
|
+
if (!controlled)
|
|
368
|
+
startedAt = await admitted();
|
|
369
|
+
const { prompt, carried } = assemblePrompt(room, member, cursor, text, relayIds);
|
|
370
|
+
const prepared = member.childSessionId !== undefined && facade.isPreparedMember?.(member.childSessionId) === true;
|
|
371
|
+
const pendingRun = prepared
|
|
372
|
+
? facade.start(room.id, provider, [{ type: 'text', text: prompt }], { preparedMemberId: member.childSessionId, ...admission })
|
|
373
|
+
: member.childSessionId === undefined
|
|
374
|
+
// The invite-time model lands as the delegation's own model: the facade
|
|
375
|
+
// records it with the first start and every later resume re-requests it
|
|
376
|
+
// (providers bind it at spawn for exec and live alike).
|
|
377
|
+
? facade.start(room.id, provider, [{ type: 'text', text: prompt }], member.model === undefined && member.cwd === undefined && !controlled ? undefined : { ...admission, ...member.model === undefined ? {} : { model: member.model }, ...member.cwd === undefined ? {} : { cwd: member.cwd } })
|
|
378
|
+
: facade.resume(room.id, provider, member.childSessionId, [{ type: 'text', text: prompt }], member.cwd === undefined && !controlled ? undefined : { ...admission, ...member.cwd === undefined ? {} : { cwd: member.cwd } });
|
|
379
|
+
// Existing members can enter core's queue immediately. A fresh/prepared
|
|
380
|
+
// identity must publish its first handle before the next submission resumes it.
|
|
381
|
+
if (controlled && member.childSessionId !== undefined && !prepared)
|
|
382
|
+
submitted();
|
|
383
|
+
const run = await pendingRun;
|
|
384
|
+
if (startedAt === undefined) {
|
|
385
|
+
await run.dispose();
|
|
386
|
+
throw new Error('Provider published a room run without admission');
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
if (member.childSessionId === undefined) {
|
|
390
|
+
// Persist identity before releasing fresh-member submissions.
|
|
391
|
+
room.append('room/member-updated', { name: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name, childSessionId: run.id });
|
|
392
|
+
}
|
|
393
|
+
await this.markSent(room, [
|
|
394
|
+
...carried,
|
|
395
|
+
...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
|
|
396
|
+
]);
|
|
397
|
+
await this.ctx.sessions.flush(room);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
// Native work already exists. Never claim a terminal room outcome while
|
|
401
|
+
// that work is still active merely because publishing its handle failed.
|
|
402
|
+
await run.result.catch(() => { });
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
if (controlled)
|
|
406
|
+
submitted();
|
|
407
|
+
const result = await run.result;
|
|
408
|
+
const output = runOutputText(result.output);
|
|
409
|
+
const interrupted = result.stopReason === 'completed' ? undefined
|
|
410
|
+
: result.stopReason === 'aborted' ? 'cancelled' : 'failed';
|
|
411
|
+
const speech = output !== '' || interrupted === undefined ? room.append('room/speech', {
|
|
412
|
+
member: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name,
|
|
413
|
+
text: output,
|
|
414
|
+
childSessionId: run.id,
|
|
415
|
+
durationMs: Date.now() - startedAt,
|
|
416
|
+
...interrupted === undefined ? {} : { interrupted },
|
|
417
|
+
}) : undefined;
|
|
418
|
+
if (speech !== undefined)
|
|
419
|
+
await this.ctx.sessions.flush(room);
|
|
420
|
+
if (result.stopReason === 'completed') {
|
|
421
|
+
// The fallback notification channel: a reply whose trailing own line is
|
|
422
|
+
// `@name <content>` (the roster-taught format) becomes a pending relay
|
|
423
|
+
// at the gate — the same shape the family bridge's member_message
|
|
424
|
+
// produces, so downstream handling is identical.
|
|
425
|
+
const directive = parseRelayDirective(runOutputText(result.output));
|
|
426
|
+
const roster = replay(room.snapshotEvents()).members;
|
|
427
|
+
if (directive !== undefined && directive.to !== member.name
|
|
428
|
+
&& roster.some(entry => entry.name === directive.to)) {
|
|
429
|
+
room.append('room/relay', {
|
|
430
|
+
id: randomUUID(),
|
|
431
|
+
from: member.name,
|
|
432
|
+
to: directive.to,
|
|
433
|
+
content: directive.content,
|
|
434
|
+
provenance: { kind: 'speech-fallback', speechSeq: speech.seq },
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
await this.ctx.sessions.flush(room);
|
|
438
|
+
return { state: 'done', text: runOutputText(result.output) };
|
|
439
|
+
}
|
|
440
|
+
else if (result.stopReason === 'aborted') {
|
|
441
|
+
return { state: 'cancelled', text: output };
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
return { state: 'failed', text: output, error: `the member run ended with stopReason "${result.stopReason}"` };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Settle only this execution and its own task rows, preserving newer runs. */
|
|
448
|
+
async settle(room, member, runId, startedAt, outcome) {
|
|
449
|
+
const events = room.snapshotEvents();
|
|
450
|
+
const memberName = replay(events).members.find(entry => memberId(events, entry) === memberId(events, member))?.name ?? member.name;
|
|
451
|
+
const terminal = events.find(event => event.type === 'room/run-state' && event.data.runId === runId && event.data.state !== 'running');
|
|
452
|
+
if (terminal?.type === 'room/run-state' && terminal.data.state !== 'running') {
|
|
453
|
+
return { ...outcome, state: terminal.data.state, ...terminal.data.error === undefined ? {} : { error: terminal.data.error } };
|
|
454
|
+
}
|
|
455
|
+
room.append('room/run-state', {
|
|
456
|
+
member: memberName, runId, state: outcome.state, startedAt, elapsedMs: Date.now() - startedAt,
|
|
457
|
+
...outcome.error === undefined ? {} : { error: outcome.error },
|
|
458
|
+
});
|
|
459
|
+
for (const task of replay(room.snapshotEvents()).tasks) {
|
|
460
|
+
if (task.member === memberName && task.status === 'in_progress' && task.deliveryId === runId) {
|
|
461
|
+
room.append('room/task-updated', { id: task.id, status: outcome.state });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
await this.ctx.sessions.flush(room);
|
|
465
|
+
return outcome;
|
|
466
|
+
}
|
|
467
|
+
}
|