dsh-cindy-host 0.1.34 → 0.1.36
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 +13 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/cindy-channels.js +13 -0
- package/src/cindy-message-row.js +11 -1
- package/src/cindy-session-row.js +1 -0
- package/src/continuation-protocol.js +31 -0
- package/src/diagnostic-presentation.js +65 -0
- package/src/dsh-input-delivery.js +37 -3
- package/src/dsh-message-fold.js +17 -11
- package/src/dsh-plugin.js +11 -1
- package/src/dsh-session-source.js +2 -2
- package/src/host-input-delivery.js +23 -4
- package/src/host-settings.js +15 -1
- package/src/host-version.js +1 -1
- package/src/host.js +134 -17
- package/src/turn-error.js +78 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.36 — 2026-11-06
|
|
4
|
+
|
|
5
|
+
- Project proven native interruption identities and timestamp tuples consistently into session list/get, boundaries and reconnect updates. Clear non-pending/hidden/running presentation without fabricating transcript-clear or completion facts.
|
|
6
|
+
- Serve existing interruption acknowledgement and committed-error dismissal calls through authenticated owner-scoped, revisioned native settings. Store hashed display preferences only, verify authoritative readback, guard source/controller/tail races, and overlay history without rewriting source events or receipt evidence.
|
|
7
|
+
- Treat the pinned current Cindy continuation prompt as a new explicit durable human input with planMode:false. Capture and recheck the native tail around proven scoped plan-off, deduplicate continuations for that tail, and reconcile superseded/non-admitted refusals as removed receipts instead of retrying automatically. Legacy non-durable fallback refuses this control contract.
|
|
8
|
+
- Extend Host/ledger/native-input regressions and opt-in read-only Cindy consumer contracts. Existing wire limitations remain explicit: no atomic CAS against unrelated desktop writers, screen-generation token in acknowledgement, immediate client rollback of failed ignores, or automatic replay of unknown tool outcomes.
|
|
9
|
+
|
|
10
|
+
## 0.1.35 — 2026-11-05
|
|
11
|
+
|
|
12
|
+
- Show distinct native interruption, cancellation, sign-out, output-limit and blocked-turn notices without inventing model failures. Keep explicit native scheduled/started retries nonterminal, with safe known-code/count/delay details and stable live/history identities.
|
|
13
|
+
- Match native cold-query diagnostic recovery only when both the native session and Agent are absent. Apply display-only interrupted-turn closers without changing stored events, receipt evidence or live tool outcomes; restore notices before terminal envelopes under the existing authorization and generation guards.
|
|
14
|
+
- Render canonical tool-role results alongside legacy nested results, preserving call pairing, output and error/unknown-outcome markers. Extend Host diagnostic regressions and the opt-in actual mobile contract; source tests are not handset or arbitrary desktop-UI parity certification.
|
|
15
|
+
|
|
3
16
|
## 0.1.34 — 2026-11-05
|
|
4
17
|
|
|
5
18
|
- Project durable native failed turns as stable, sanitized error rows and terminal maker events in both cold history and live delivery. Preserve native failure/retry ownership, expose only fixed known-code messages and validated HTTP status, and repair reconnect state without stopping newer turns or leaking provider diagnostics.
|
package/README.md
CHANGED
|
@@ -4,16 +4,20 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
|
|
|
4
4
|
|
|
5
5
|
## Requirements and installation
|
|
6
6
|
|
|
7
|
-
Version **0.1.
|
|
7
|
+
Version **0.1.36** adds native interruption banner/status metadata, owner-scoped interruption acknowledgement and error dismissal, and guarded manual continuation with verified native plan-mode off. It retains the earlier safe diagnostics, question synchronization, authentication, reasoning streaming and native capability subsets.
|
|
8
8
|
|
|
9
9
|
It supports the exact **DSH 0.2.2-alpha.2** carrier, while retaining `0.2.1-rc.3`, `0.2.1-rc.4` and `0.2.1-rc.5`. Alpha requires **Node.js >=24.9** (tested with 24.12); the older carriers retain this plugin's Node.js >=22.16 minimum. Agent/session/settings peers and alpha Schemastery are explicitly allowed, not a blanket promise for future prereleases. The full Web installation gate uses DSH **0.2.1-rc.5**; alpha core-contract checks do not establish alpha Web/relay/handset acceptance. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
dsh plugin --profile <profile> add dsh-cindy-host@0.1.
|
|
12
|
+
dsh plugin --profile <profile> add dsh-cindy-host@0.1.36
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
|
|
16
16
|
|
|
17
|
+
## 0.1.36: interruption controls
|
|
18
|
+
|
|
19
|
+
The existing Cindy acknowledgement/dismissal actions update owner-scoped display preferences, not failure or completion facts. Manual continuation is a new human input; it verifies the current native tail and effective plan-mode off rather than retrying old tool calls. These changes are Host-only: Cindy is a read-only protocol/test dependency. See the [diagnostic protocol and limitations](<doc/model-errors.md>) for persistence, concurrency and current-client boundaries. Source contracts do not establish live-handset acceptance.
|
|
20
|
+
|
|
17
21
|
## 0.1.32: credentials without keytar
|
|
18
22
|
|
|
19
23
|
No native npm credential module or C++ build toolchain is required. Linux uses `secret-tool` with an unlocked Secret Service (headless sessions need their own DBus/keyring setup); macOS uses the system Keychain through `/usr/bin/security`. Both keep the existing `DSH Cindy Host` service and installation-specific account.
|
package/package.json
CHANGED
package/src/cindy-channels.js
CHANGED
|
@@ -21,6 +21,7 @@ import { toGoalStatusPayload } from './host-goals.js';
|
|
|
21
21
|
import { hostAttachmentsOf } from './host-attachments.js';
|
|
22
22
|
import { historyLocateOptions, locateCommittedMessages, capReferenceRows } from './host-history-locate.js';
|
|
23
23
|
import { assertNoStructuredSessionReferences } from './session-reference-boundary.js';
|
|
24
|
+
import { continuationKind } from './continuation-protocol.js';
|
|
24
25
|
|
|
25
26
|
async function deliveryReply(request, operation) {
|
|
26
27
|
try { return invokeResult(request, await operation()); }
|
|
@@ -217,6 +218,8 @@ export const SUPPORTED_CHANNELS = Object.freeze([
|
|
|
217
218
|
'local-db:sessions:list',
|
|
218
219
|
'local-db:sessions:get',
|
|
219
220
|
'local-db:messages:list',
|
|
221
|
+
'local-db:messages:dismiss-error',
|
|
222
|
+
'local-db:sessions:ack-interrupted',
|
|
220
223
|
'local-db:messages:around',
|
|
221
224
|
'local-db:messages:around-client-id',
|
|
222
225
|
'local-db:sessions:patch-meta',
|
|
@@ -648,6 +651,14 @@ export function createChannelRouter({
|
|
|
648
651
|
return invokeResult(request, sessionListPage(await sessionRows(), args));
|
|
649
652
|
}
|
|
650
653
|
|
|
654
|
+
if (channel === 'local-db:sessions:ack-interrupted' || channel === 'local-db:messages:dismiss-error') {
|
|
655
|
+
const sid = args[0]; const cid = args[1]; const caps = capabilitiesNow();
|
|
656
|
+
if (typeof sid !== 'string' || !sid || args.length !== (channel === 'local-db:sessions:ack-interrupted' ? 1 : 2) || (channel === 'local-db:messages:dismiss-error' && (typeof cid !== 'string' || !cid))) return invokeError(request, 'INVALID_PARAMS', 'Session and message identities are required');
|
|
657
|
+
const action = channel === 'local-db:sessions:ack-interrupted' ? caps.ackInterrupted : caps.dismissError;
|
|
658
|
+
if (typeof action !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'Diagnostic presentation actions are unavailable');
|
|
659
|
+
return deliveryReply(request, () => channel === 'local-db:sessions:ack-interrupted' ? action(sid, request.src) : action(sid, cid, request.src));
|
|
660
|
+
}
|
|
661
|
+
|
|
651
662
|
if (channel === 'local-db:sessions:get') {
|
|
652
663
|
const sessionId = String(args[0] ?? '');
|
|
653
664
|
// A known blank session must be readable before its first enqueue. The
|
|
@@ -1164,6 +1175,8 @@ export function createChannelRouter({
|
|
|
1164
1175
|
if (delivery) return deliveryReply(request, () => channel === 'maker:input:steer' ? delivery.steer(sessionId, args[1], args[2] ?? {}) : delivery.accept(sessionId, args[1], args[2] ?? {}));
|
|
1165
1176
|
}
|
|
1166
1177
|
|
|
1178
|
+
if (continuationKind(extractSendText(args[1]))) return invokeError(request, 'NOT_AVAILABLE', 'Safe manual continuation requires native durable input delivery');
|
|
1179
|
+
|
|
1167
1180
|
// A `steer` naming an item DSH already holds is a **promotion** of that
|
|
1168
1181
|
// queued item, not a new message: the controller sends the queued row back
|
|
1169
1182
|
// with `removeFromQueue`. Re-sending its text would duplicate it, which is
|
package/src/cindy-message-row.js
CHANGED
|
@@ -288,12 +288,22 @@ const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
|
|
|
288
288
|
*/
|
|
289
289
|
export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
|
|
290
290
|
const rows = [];
|
|
291
|
+
// Canonical DSH results use a tool-role message with root outcome/call ID,
|
|
292
|
+
// not the legacy user-role nested tool-result block. Keep both formats.
|
|
293
|
+
if (message?.role === 'tool') {
|
|
294
|
+
if (message.source?.kind !== undefined && message.source.kind !== 'tool') return rows;
|
|
295
|
+
if (typeof message.toolCallId !== 'string' || !message.toolCallId) return rows;
|
|
296
|
+
const id = `${sessionId}:${message.id}:0`;
|
|
297
|
+
return [{ id, clientId: id, sessionId, role: 'tool_result', toolUseId: message.toolCallId, agentMeta: null,
|
|
298
|
+
createdAt: createdAt ?? now().toISOString(),
|
|
299
|
+
content: { text: flattenToolResult(message.content), isError: message.isError === true } }];
|
|
300
|
+
}
|
|
291
301
|
const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
|
|
292
302
|
// `system` (the prompt injection), a hook's injected turn, or any role this
|
|
293
303
|
// Host has never seen: none of them is something a participant said.
|
|
294
304
|
if (role === undefined) return rows;
|
|
295
305
|
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
296
|
-
// DSH
|
|
306
|
+
// Older DSH messages record a tool result as a `user`-role message whose source is
|
|
297
307
|
// `{kind: 'tool'}`. Its content block is what makes it a result row; a *text*
|
|
298
308
|
// block on such a message is not the user speaking, so it is not rendered as
|
|
299
309
|
// one.
|
package/src/cindy-session-row.js
CHANGED
|
@@ -126,6 +126,7 @@ export function toCindySessionListRow(item, { now = () => new Date(), device, de
|
|
|
126
126
|
// Not part of the stored row's contract, but harmless and honest: the
|
|
127
127
|
// controller reads running state from `maker:list-active`, not from here.
|
|
128
128
|
running: item.running === true,
|
|
129
|
+
...(Object.hasOwn(item, 'activeTurnStartedAt') ? { activeTurnStartedAt: item.activeTurnStartedAt, interruptedTurnStartedAt: item.interruptedTurnStartedAt, lastTurnEndedAt: item.lastTurnEndedAt, clearedAt: item.clearedAt ?? null } : {}),
|
|
129
130
|
// Which device this row came from. The controller's home aggregates sessions
|
|
130
131
|
// from every linked computer and filters with
|
|
131
132
|
// `canonicalDeviceId ?? deviceLinkDeviceId === selectedDeviceId`
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pinned Cindy maker-shared/syntheticTrigger constants; actual-consumer tests
|
|
2
|
+
// assert byte equality. These are explicit manual inputs, never tool retries.
|
|
3
|
+
const COMMON = ' Review the conversation above to determine how far the task progressed,'
|
|
4
|
+
+ ' then continue from where it left off.'
|
|
5
|
+
+ ' Do not repeat steps that already completed and had external effects'
|
|
6
|
+
+ ' (messages or comments already sent, commits already pushed, files already written).';
|
|
7
|
+
export const CONTINUE_INTERRUPTED = '[UI_ACTION_TRIGGER] The previous turn was interrupted because the app exited mid-run.' + COMMON;
|
|
8
|
+
export const CONTINUE_ERROR = '[UI_ACTION_TRIGGER] The previous turn errored partway through.' + COMMON;
|
|
9
|
+
export function continuationKind(text) { return text === CONTINUE_INTERRUPTED ? 'interrupted' : text === CONTINUE_ERROR ? 'error' : null; }
|
|
10
|
+
|
|
11
|
+
/** Public native getters/projections prove the effective switch; never parse command prose. */
|
|
12
|
+
export async function ensureNativeContinuationPlanOff({agent,controller,getService}) {
|
|
13
|
+
try {
|
|
14
|
+
const registry=agent?.ctx?.get?.('sessionProjections')??getService('sessionProjections');
|
|
15
|
+
const read=()=>controller?.get?.(agent)??registry?.stateOf?.(agent.session,'plan');
|
|
16
|
+
const off=()=>{const state=read();return state?.active===false&&state.pending!==true&&state.wanted!==true&&!state.running;};
|
|
17
|
+
// A logged projection alone cannot rule out a controller-private pending
|
|
18
|
+
// entry. Without the live getter, issue off even when logged active=false.
|
|
19
|
+
if(typeof controller?.get==='function'&&off())return;
|
|
20
|
+
if(typeof controller?.set==='function')await controller.set(agent,false);
|
|
21
|
+
else {
|
|
22
|
+
const commands=getService('commands');
|
|
23
|
+
if(typeof commands?.find!=='function'||typeof agent?.ctx?.get!=='function')throw Error('Native plan composition is not observable');
|
|
24
|
+
if(commands.find(agent,'plan')===undefined&&!controller)return; // exact resolved agent has no plan-mode owner
|
|
25
|
+
if(typeof commands.execute!=='function')throw Error('Native plan control is unavailable');
|
|
26
|
+
const result=await commands.execute(agent,'/plan off',[],AbortSignal.timeout(30000));
|
|
27
|
+
if(result?.result?.kind==='error')throw Error('Native plan-mode switch was refused');
|
|
28
|
+
}
|
|
29
|
+
if(!off())throw Error('Native plan-mode off is not committed');
|
|
30
|
+
} catch(cause) { throw Object.assign(new Error('Cannot verify native plan mode is off; continuation was not sent'),{code:'PLAN_MODE_UNAVAILABLE',cause}); }
|
|
31
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
3
|
+
const unavailable = message => Object.assign(new Error(message), { code: 'NOT_AVAILABLE' });
|
|
4
|
+
export const EMPTY_INTERRUPTION = Object.freeze({ activeTurnStartedAt: null, interruptedTurnStartedAt: null, lastTurnEndedAt: null, clearedAt: null });
|
|
5
|
+
|
|
6
|
+
/** Presentation identities use the original turn/start, never a synthetic closer. */
|
|
7
|
+
export function nativeInterruption(sessionId, start, end, priorEndedAt) {
|
|
8
|
+
if (end?.data?.reason?.kind !== 'interrupted' || !Number.isSafeInteger(start?.data?.turn) || start.data.turn < 0 || start?.data?.turn !== end?.data?.turn
|
|
9
|
+
|| !Number.isSafeInteger(end.seq) || end.seq <= start?.seq || !Number.isFinite(end.time)
|
|
10
|
+
|| !Number.isSafeInteger(start?.seq) || start.seq < 0 || !Number.isFinite(start.time) || start.time <= 0
|
|
11
|
+
|| (Number.isFinite(priorEndedAt) && priorEndedAt >= start.time)) return null;
|
|
12
|
+
return { id: `${sessionId}:interrupted:${start.seq}:${start.time}:${start.data.turn}`,
|
|
13
|
+
startedAt: start.time, priorEndedAt: Number.isFinite(priorEndedAt) ? priorEndedAt : null };
|
|
14
|
+
}
|
|
15
|
+
export function advanceInterruptionMetadata(state, event, sessionId) {
|
|
16
|
+
const previous = state ?? { start: null, priorEndedAt: null, interruption: null };
|
|
17
|
+
if (event?.type === 'turn/start') return { ...previous, start: { seq: event.seq, time: event.time, data: { turn: event.data?.turn } }, interruption: null };
|
|
18
|
+
if (event?.type !== 'turn/end') return previous;
|
|
19
|
+
return { ...previous, interruption: nativeInterruption(sessionId, previous.start, event, previous.priorEndedAt),
|
|
20
|
+
priorEndedAt: event.data?.reason?.kind === 'interrupted' ? previous.priorEndedAt : Number.isFinite(event.time) ? event.time : previous.priorEndedAt };
|
|
21
|
+
}
|
|
22
|
+
export function diagnosticOwner(namespace, profile = '') {
|
|
23
|
+
return typeof namespace === 'string' && namespace ? hash(`${profile}|${namespace}`) : null;
|
|
24
|
+
}
|
|
25
|
+
export function preferenceHas(data, owner, sid, kind, id) {
|
|
26
|
+
return Boolean(owner && data?.[owner]?.[hash(sid)]?.[kind]?.[hash(id)] === true);
|
|
27
|
+
}
|
|
28
|
+
export function interruptionFields(candidate, running, hidden, data, owner, sid) {
|
|
29
|
+
if (!candidate || running || hidden || preferenceHas(data, owner, sid, 'interruptions', candidate.id)) return { ...EMPTY_INTERRUPTION };
|
|
30
|
+
return { activeTurnStartedAt: candidate.startedAt, interruptedTurnStartedAt: candidate.startedAt, lastTurnEndedAt: candidate.priorEndedAt, clearedAt: null };
|
|
31
|
+
}
|
|
32
|
+
export function projectDismissedErrors(rows, data, owner) {
|
|
33
|
+
return rows.map(row => row?.role === 'error' && preferenceHas(data, owner, row.sessionId, 'errors', row.clientId)
|
|
34
|
+
? { ...row, content: { ...row.content, dismissed: true } } : row);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One transactional settings owner; no optimistic mirror of error/turn facts.
|
|
38
|
+
* Leaf writes preserve other grants/preferences. Revision conflicts are retried
|
|
39
|
+
* from describe, and successful writes are verified by authoritative readback. */
|
|
40
|
+
export function diagnosticPreferenceStore(settings, ns) {
|
|
41
|
+
function describe() {
|
|
42
|
+
if (typeof settings?.describe !== 'function' || typeof settings?.mutate !== 'function') throw unavailable('Diagnostic preferences require revisioned native settings');
|
|
43
|
+
const entry = settings.describe().find(entry => entry.ns === ns);
|
|
44
|
+
if (!entry || !Number.isSafeInteger(entry.revision)) throw unavailable('Diagnostic settings namespace is unavailable');
|
|
45
|
+
return entry;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
snapshot() { try { return describe().value?.diagnosticPresentationV1 ?? {}; } catch { return {}; } },
|
|
49
|
+
async commit({ owner, sessionId, kind, id }, valid = () => true) {
|
|
50
|
+
if (!owner || !['interruptions', 'errors'].includes(kind)) throw unavailable('No authenticated diagnostic preference owner');
|
|
51
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
52
|
+
if (!valid()) throw unavailable('Diagnostic action scope changed');
|
|
53
|
+
const entry = describe();
|
|
54
|
+
if (preferenceHas(entry.value?.diagnosticPresentationV1, owner, sessionId, kind, id)) return;
|
|
55
|
+
try {
|
|
56
|
+
await settings.mutate(ns, [{ op: 'set', path: ['diagnosticPresentationV1', owner, hash(sessionId), kind, hash(id)], value: true }], entry.revision);
|
|
57
|
+
} catch (error) { if (error?.code === 'SETTINGS_CONFLICT') continue; throw error; }
|
|
58
|
+
if (!valid()) throw unavailable('Diagnostic action completed in an expired scope');
|
|
59
|
+
if (!preferenceHas(describe().value?.diagnosticPresentationV1, owner, sessionId, kind, id)) throw unavailable('Diagnostic preference was not committed');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw unavailable('Diagnostic settings changed concurrently; retry the action');
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createAttachmentMaterializer, hostAttachmentsOf } from './host-attachments.js';
|
|
2
2
|
import { foldSessionEvent } from './dsh-message-fold.js';
|
|
3
|
+
import { interruptedTurnClosers } from '@deepseek-ai/dsh-session';
|
|
4
|
+
import { turnStateFromEvents } from './turn-error.js';
|
|
5
|
+
import { continuationKind } from './continuation-protocol.js';
|
|
3
6
|
import { queuedRowFromController } from './host-input-queue.js';
|
|
4
7
|
import { deliveryError } from './host-input-delivery-store.js';
|
|
5
8
|
import { assertNoStructuredSessionReferences } from './session-reference-boundary.js';
|
|
@@ -22,7 +25,7 @@ export function durableInputEvidence(events,sessionId) {
|
|
|
22
25
|
const inbox=Object.entries(queues).flatMap(([placement,items])=>items.map(message=>({id:message.id,clientId:message.source?.kind==='user'&&typeof message.source.rpcId==='string'?message.source.rpcId:message.id,placement,message,foreign:message.source?.kind!=='user'})));
|
|
23
26
|
return {sessionId,users,inbox,throughSeq:events.length-1};
|
|
24
27
|
}
|
|
25
|
-
export function createDshInputDeliveryBackend({getServices,sessionController,getSession}){
|
|
28
|
+
export function createDshInputDeliveryBackend({getServices,sessionController,getSession,ensurePlanModeOff}){
|
|
26
29
|
let active=true;
|
|
27
30
|
function services(){if(!active)throw deliveryError('DELIVERY_BACKEND_STOPPED','Input delivery service was detached');const s=getServices();if(!s.sessions||typeof s.sessions.flush!=='function'||!s.persistence||typeof s.persistence.open!=='function')throw deliveryError('DELIVERY_NOT_AVAILABLE','Durable DSH session services are not mounted');return s;}
|
|
28
31
|
async function readLog(sid){
|
|
@@ -33,10 +36,28 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
|
|
|
33
36
|
}
|
|
34
37
|
async function agentFor(sid){const resolved=await sessionController.resolveAgent(sid);if(resolved?.error)throw resolved.error;if(!resolved?.agent)throw deliveryError('DELIVERY_AGENT_UNAVAILABLE','DSH did not resolve an agent');return resolved.agent;}
|
|
35
38
|
async function flush(sid){const s=services();const session=s.sessions.get(sid);if(!session||!await s.sessions.flush(session))throw deliveryError('DELIVERY_DURABILITY_UNAVAILABLE','Cannot verify the DSH mutation durability');}
|
|
39
|
+
async function continuationTail(sid,kind,clientId) {
|
|
40
|
+
const persisted=await readLog(sid); const s=services();
|
|
41
|
+
const agent=s.agents?.get(sid);
|
|
42
|
+
if(agent?.status==='running')throw deliveryError('CONTINUATION_SUPERSEDED','The session has already started another turn');
|
|
43
|
+
const events=!s.sessions.get(sid)&&!agent?[...persisted,...interruptedTurnClosers(persisted)]:persisted;
|
|
44
|
+
const state=turnStateFromEvents(events,sid);
|
|
45
|
+
const id=kind==='interrupted'?state?.interruption?.id:state?.error?.clientId;
|
|
46
|
+
if(!id||state.running||events.some(event=>event.seq>state.boundarySeq&&event.type==='user/message'&&event.data?.source?.kind==='user'&&event.data.source.rpcId!==clientId)
|
|
47
|
+
||durableInputEvidence(persisted,sid).inbox.some(item=>item.clientId!==clientId))throw deliveryError('CONTINUATION_SUPERSEDED','The original continuation tail is no longer current');
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
36
50
|
const backend={
|
|
37
51
|
available(){try{services();return true;}catch{return false;}},
|
|
38
52
|
stop(){active=false;},
|
|
39
|
-
async readRows(sid){
|
|
53
|
+
async readRows(sid){
|
|
54
|
+
const persisted=await readLog(sid);const s=services();
|
|
55
|
+
// Match native session-query cold reads. This is a display-only overlay;
|
|
56
|
+
// evidence()/receipts still read the untouched persisted prefix. A live
|
|
57
|
+
// session or agent must never be diagnosed as a crashed writer.
|
|
58
|
+
const cold=!s.sessions.get(sid)&&typeof s.agents?.get==='function'&&!s.agents.get(sid);
|
|
59
|
+
const events=cold?[...persisted,...interruptedTurnClosers(persisted)]:persisted;
|
|
60
|
+
const rows=[];for(const event of events){const folded=foldSessionEvent(event,{sessionId:sid});folded.forEach((row,index)=>{const rowid=event.seq*65536+index;if(!Number.isSafeInteger(rowid))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Message order exceeds the safe protocol range');rows.push({...row,rowid});});}return rows.reverse();},
|
|
40
61
|
async evidence(sid){const events=await readLog(sid);const proof=durableInputEvidence(events,sid);const agent=getServices().agents?.get(sid);return {...proof,running:agent?.status==='running',live:agent!==undefined};},
|
|
41
62
|
async prepare(sid,row,{resolveAttachmentRef,mode='queue'}={}){
|
|
42
63
|
assertNoStructuredSessionReferences(row);
|
|
@@ -46,13 +67,26 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
|
|
|
46
67
|
const materializer=createAttachmentMaterializer({fileSystem:getServices().fileSystem,resolveAttachmentRef,captureOnly:true});
|
|
47
68
|
const owned=await materializer.materialize({attachments:incoming,signal:AbortSignal.timeout(30000)});
|
|
48
69
|
if(owned.dropped.length)throw deliveryError('ATTACHMENT_UNAVAILABLE','Cannot durably receive all attachments: '+owned.dropped.map(x=>x.reason).join(', '));
|
|
70
|
+
const kind=continuationKind(text);
|
|
71
|
+
const continuation=kind?{kind,tailId:await continuationTail(sid,kind,row.clientId)}:null;
|
|
72
|
+
if(continuation&&row.createOpts?.planMode!==false)throw deliveryError('INVALID_PARAMS','Manual continuation requires explicit planMode:false');
|
|
49
73
|
const queued=queuedRowFromController({clientId:row.clientId,text,session});if(!queued)throw deliveryError('DELIVERY_SESSION_UNAVAILABLE','Session working directory is unavailable');
|
|
50
|
-
return {payload:{text,assets:owned.parts,consumed:owned.consumed,mode},queued:{...queued,files:incoming,chatMessage:{...queued.chatMessage,...(row.chatMessage?.images?{images:row.chatMessage.images}:{})}}};
|
|
74
|
+
return {payload:{text,assets:owned.parts,consumed:owned.consumed,mode,...(continuation?{continuation}: {})},queued:{...queued,...(continuation?{createOpts:{...queued.createOpts,planMode:false}}:{}),files:incoming,chatMessage:{...queued.chatMessage,...(row.chatMessage?.images?{images:row.chatMessage.images}:{})}}};
|
|
51
75
|
},
|
|
52
76
|
async dispatch(entry,{releaseAttachmentRef}={}){
|
|
53
77
|
assertNoStructuredSessionReferences(entry.request,entry.payload);
|
|
54
78
|
let agent;
|
|
55
79
|
try{agent=await agentFor(entry.sessionId);}catch(error){error.deliveryNotAdmitted=true;throw error;}
|
|
80
|
+
if(entry.payload.continuation) {
|
|
81
|
+
try {
|
|
82
|
+
const origin=entry.payload.continuation;
|
|
83
|
+
if(await continuationTail(entry.sessionId,origin.kind,entry.clientId)!==origin.tailId)throw deliveryError('CONTINUATION_SUPERSEDED','A newer task superseded this manual continuation');
|
|
84
|
+
if(typeof ensurePlanModeOff!=='function')throw deliveryError('PLAN_MODE_UNAVAILABLE','Cannot verify native plan mode is off');
|
|
85
|
+
await ensurePlanModeOff({sessionId:entry.sessionId,agent});
|
|
86
|
+
if(await continuationTail(entry.sessionId,origin.kind,entry.clientId)!==origin.tailId)throw deliveryError('CONTINUATION_SUPERSEDED','A newer task superseded this manual continuation');
|
|
87
|
+
services();
|
|
88
|
+
} catch(error) { error.deliveryNotAdmitted=true; if(error.code==='CONTINUATION_SUPERSEDED')error.deliverySuperseded=true; throw error; }
|
|
89
|
+
}
|
|
56
90
|
const content=entry.payload.text?[{type:'text',text:entry.payload.text}]:[];
|
|
57
91
|
for(const asset of entry.payload.assets??[]){
|
|
58
92
|
if(asset.mediaType)content.push({type:'image',mediaType:asset.mediaType,data:asset.data,name:asset.name});
|
package/src/dsh-message-fold.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
|
|
19
19
|
import { createReasoningStream } from './reasoning-stream.js';
|
|
20
|
-
import {
|
|
20
|
+
import { turnDiagnosticRow, turnStateFromEvents } from './turn-error.js';
|
|
21
|
+
import { advanceInterruptionMetadata } from './diagnostic-presentation.js';
|
|
21
22
|
import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
|
|
22
23
|
|
|
23
24
|
/** Default page size when the controller does not ask for one. */
|
|
@@ -82,8 +83,8 @@ export const TRANSCRIPT_CACHE_SESSIONS = 3;
|
|
|
82
83
|
*/
|
|
83
84
|
export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
|
|
84
85
|
if (event === null || typeof event !== 'object') return [];
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
86
|
+
const diagnostic = turnDiagnosticRow(event, sessionId);
|
|
87
|
+
if (diagnostic) return [diagnostic];
|
|
87
88
|
if (!isAppendSurfaceEvent(event)) return [];
|
|
88
89
|
let message;
|
|
89
90
|
try {
|
|
@@ -383,16 +384,20 @@ export function createMessageReader({
|
|
|
383
384
|
const entries = [];
|
|
384
385
|
const reasoning = createReasoningStream(sessionId);
|
|
385
386
|
let lastSeq = null;
|
|
387
|
+
let nativeMetadata = null;
|
|
388
|
+
let nativeValid = true;
|
|
386
389
|
for (const event of events) {
|
|
387
390
|
if (event === null || typeof event !== 'object') continue;
|
|
388
391
|
// Every event's seq counts, not only the ones that render: contiguity is a
|
|
389
392
|
// property of the log, and a caller comparing against renders would read a
|
|
390
393
|
// gap as "nothing happened here".
|
|
394
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0 || (lastSeq !== null && event.seq !== lastSeq + 1)) nativeValid = false;
|
|
391
395
|
if (Number.isFinite(event.seq)) lastSeq = event.seq;
|
|
396
|
+
nativeMetadata = advanceInterruptionMetadata(nativeMetadata, event, sessionId);
|
|
392
397
|
reasoning.consume(event);
|
|
393
|
-
const
|
|
394
|
-
if (
|
|
395
|
-
entries.push({ rows: [
|
|
398
|
+
const diagnostic = turnDiagnosticRow(event, sessionId);
|
|
399
|
+
if (diagnostic) {
|
|
400
|
+
entries.push({ rows: [diagnostic] });
|
|
396
401
|
continue;
|
|
397
402
|
}
|
|
398
403
|
const todo = entryForTodoWrite(event);
|
|
@@ -418,7 +423,7 @@ export function createMessageReader({
|
|
|
418
423
|
promptClientId: promptRpcIdOf(event),
|
|
419
424
|
});
|
|
420
425
|
}
|
|
421
|
-
return { entries, lastSeq, reasoning };
|
|
426
|
+
return { entries, lastSeq, reasoning, nativeValid, nativeMetadata: nativeValid ? nativeMetadata : null };
|
|
422
427
|
}
|
|
423
428
|
|
|
424
429
|
/** Drop the least recently used transcripts, keeping the window bounded. */
|
|
@@ -498,9 +503,9 @@ export function createMessageReader({
|
|
|
498
503
|
// invalidation also detaches this flight so a post-invalidation read starts
|
|
499
504
|
// fresh; an older completion must never overwrite that newer generation.
|
|
500
505
|
if (seeding.get(sessionId) === flight && !flight.dirty) {
|
|
501
|
-
touch(sessionId, { rows, reasoning: read.reasoning, seq: read.lastSeq, at: clock });
|
|
506
|
+
touch(sessionId, { rows, reasoning: read.reasoning, seq: read.lastSeq, at: clock, nativeValid: read.nativeValid, nativeMetadata: read.nativeMetadata });
|
|
502
507
|
}
|
|
503
|
-
return transcripts.get(sessionId) ?? { rows, reasoning: read.reasoning };
|
|
508
|
+
return transcripts.get(sessionId) ?? { rows, reasoning: read.reasoning, nativeMetadata: read.nativeMetadata };
|
|
504
509
|
} finally {
|
|
505
510
|
// Failure is not an authoritative empty transcript. Release this flight
|
|
506
511
|
// on success or rejection, without deleting a newer read's ownership.
|
|
@@ -552,6 +557,7 @@ export function createMessageReader({
|
|
|
552
557
|
transcripts.delete(sessionId);
|
|
553
558
|
return false;
|
|
554
559
|
}
|
|
560
|
+
if (entry.nativeValid !== false) entry.nativeMetadata = advanceInterruptionMetadata(entry.nativeMetadata, event, sessionId);
|
|
555
561
|
entry.seq = seq;
|
|
556
562
|
entry.at = ++clock;
|
|
557
563
|
entry.reasoning.consume(event);
|
|
@@ -590,7 +596,7 @@ export function createMessageReader({
|
|
|
590
596
|
readMessages.count = countRows;
|
|
591
597
|
/** Exact project-grouping evidence derived from the same folded transcript. */
|
|
592
598
|
readMessages.activity = async (sessionId) => {
|
|
593
|
-
const { rows } = await ensureTranscript(sessionId);
|
|
599
|
+
const { rows, nativeMetadata } = await ensureTranscript(sessionId);
|
|
594
600
|
let userSendAt = null;
|
|
595
601
|
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
596
602
|
const row = rows[index];
|
|
@@ -599,7 +605,7 @@ export function createMessageReader({
|
|
|
599
605
|
break;
|
|
600
606
|
}
|
|
601
607
|
}
|
|
602
|
-
return { messageCount: rows.length, userSendAt };
|
|
608
|
+
return { messageCount: rows.length, userSendAt, ...(nativeMetadata ? { interruption: nativeMetadata.interruption } : {}) };
|
|
603
609
|
};
|
|
604
610
|
/**
|
|
605
611
|
* The whole cached transcript, newest first.
|
package/src/dsh-plugin.js
CHANGED
|
@@ -3,6 +3,8 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { createDshInputDeliveryBackend } from './dsh-input-delivery.js';
|
|
6
|
+
import { diagnosticPreferenceStore } from './diagnostic-presentation.js';
|
|
7
|
+
import { ensureNativeContinuationPlanOff } from './continuation-protocol.js';
|
|
6
8
|
import { DEFAULT_HOST_SETTINGS, SETTINGS_NAMESPACE, validateHostSettings } from './host-settings.js';
|
|
7
9
|
import { DshHostSource } from './dsh-host-source.js';
|
|
8
10
|
import { createSessionControllerSource } from './dsh-session-source.js';
|
|
@@ -70,6 +72,7 @@ const HostSettingsSchema = Schema.object({
|
|
|
70
72
|
deviceId: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceId),
|
|
71
73
|
deviceName: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceName),
|
|
72
74
|
sessionFlags: Schema.dict(SessionFlagSchema).default({}),
|
|
75
|
+
diagnosticPresentationV1: Schema.dict(Schema.dict(Schema.dict(Schema.dict(Schema.boolean())))).default({}),
|
|
73
76
|
});
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -697,8 +700,12 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
697
700
|
getServices: () => ({ ...deliveryServices, fileSystem: ctx.get('fs'), fileUploads: controls.fileUploads }),
|
|
698
701
|
sessionController,
|
|
699
702
|
getSession: (sessionId) => sessionSource.getSession(sessionId),
|
|
703
|
+
ensurePlanModeOff: ({agent}) => ensureNativeContinuationPlanOff({agent,controller:planModeFor(agent),getService:name=>ctx.get(name)}),
|
|
700
704
|
});
|
|
701
|
-
const committedRows = (sessionId) =>
|
|
705
|
+
const committedRows = async (sessionId) => {
|
|
706
|
+
const rows = await (inputDeliveryBackend.available() ? inputDeliveryBackend.readRows(sessionId) : readMessages.all(sessionId));
|
|
707
|
+
return options.projectMessages?.(rows) ?? rows;
|
|
708
|
+
};
|
|
702
709
|
const historyView = readMessages === undefined
|
|
703
710
|
? undefined
|
|
704
711
|
: createHistoryViewController({
|
|
@@ -1881,6 +1888,7 @@ export function apply(ctx, config) {
|
|
|
1881
1888
|
// Delete/pin and old archive overlays persist here; new archives live in
|
|
1882
1889
|
// DSH's native workspace registry, not this plugin's settings section.
|
|
1883
1890
|
persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
|
|
1891
|
+
diagnosticPreferences: diagnosticPreferenceStore(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
|
|
1884
1892
|
inputDeliveryDirectory: join(process.env.DSH_PROFILE_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', process.env.DSH_PROFILE || 'desktop'), 'state', 'dsh-cindy-host'),
|
|
1885
1893
|
|
|
1886
1894
|
identityIsCurrent: () => admitted || (!runtimeDisposed && lease()),
|
|
@@ -1914,6 +1922,7 @@ export function apply(ctx, config) {
|
|
|
1914
1922
|
// Read lazily: the runtime resolves after this seam is built, and the state changes on
|
|
1915
1923
|
// every turn boundary.
|
|
1916
1924
|
isSessionRunning: (sessionId) => runtime?.isSessionRunning?.(sessionId) === true,
|
|
1925
|
+
projectMessages: rows => runtime?.projectMessages?.(rows) ?? rows,
|
|
1917
1926
|
getProjectOrderOwner: () => runtime?.getProjectOrderOwner?.(),
|
|
1918
1927
|
});
|
|
1919
1928
|
// The filesystem is a separate service; the controller's file reads are
|
|
@@ -2033,6 +2042,7 @@ export function apply(ctx, config) {
|
|
|
2033
2042
|
// Make the failed history row readable before publishing its terminal identity.
|
|
2034
2043
|
// Everything above is synchronous, so a new turn cannot interleave here.
|
|
2035
2044
|
if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') runtime.pushTurnIdle(sessionId, event);
|
|
2045
|
+
if (typeof runtime.pushSessionDiagnostic === 'function') runtime.pushSessionDiagnostic(sessionId, event);
|
|
2036
2046
|
// Chunks are intentionally absent from deriveEventMessage. Publish a temporary
|
|
2037
2047
|
// thinking overlay too, retiring it before the durable assistant rows below.
|
|
2038
2048
|
runtime.pushSessionReasoning?.(sessionId, event, {
|
|
@@ -207,7 +207,7 @@ export function createSessionControllerSource({
|
|
|
207
207
|
const value = result.value;
|
|
208
208
|
if (!Number.isFinite(value?.messageCount) || value.messageCount < 0) return;
|
|
209
209
|
if (value.userSendAt !== null && typeof value.userSendAt !== 'string') return;
|
|
210
|
-
const evidence = { messageCount: Math.floor(value.messageCount), userSendAt: value.userSendAt };
|
|
210
|
+
const evidence = { messageCount: Math.floor(value.messageCount), userSendAt: value.userSendAt, ...(Object.hasOwn(value, 'interruption') ? { interruption: value.interruption } : {}) };
|
|
211
211
|
activityEvidenceCache.set(ids[index], evidence);
|
|
212
212
|
out?.set(ids[index], evidence);
|
|
213
213
|
});
|
|
@@ -359,7 +359,7 @@ export function createSessionControllerSource({
|
|
|
359
359
|
...(typeof selectedProvider === 'string' && selectedProvider !== '' ? { providerId: selectedProvider } : {}),
|
|
360
360
|
...(typeof selectedEffort === 'string' && selectedEffort !== '' ? { effort: selectedEffort } : {}),
|
|
361
361
|
...(currentPermission === undefined ? {} : { permissionMode: currentPermission }),
|
|
362
|
-
...(activity === undefined ? {} : { messageCount: activity.messageCount, userSendAt: activity.userSendAt }),
|
|
362
|
+
...(activity === undefined ? {} : { messageCount: activity.messageCount, userSendAt: activity.userSendAt, ...(Object.hasOwn(activity, 'interruption') ? { interruption: activity.interruption } : {}) }),
|
|
363
363
|
blank: item.blank === true,
|
|
364
364
|
};
|
|
365
365
|
});
|
|
@@ -35,7 +35,7 @@ export function createInputDeliveryCoordinator({store,backend,baseProjection=()=
|
|
|
35
35
|
if((e.phase==='stored'||e.phase==='DSH-pending')&&e.queued){const at=pending.findIndex(p=>p.clientId===e.clientId);if(at<0)pending.push(e.queued);else pending[at]=e.queued;}
|
|
36
36
|
}
|
|
37
37
|
const running=runningBySession.get(sid)??base.turnInProgress;
|
|
38
|
-
return {...base,...(typeof running==='boolean'?{turnInProgress:running}:{}),sessionId:sid,pendingQueue:pending,steeringQueueClientIds:[...steering],inputDeliveryVersion:1,clearBoundaryMs:store.boundary(sid),deliveryReceipts:ids.map(clientId=>{const e=store.get(sid,clientId);return{clientId,state:e?e.phase==='removed'?'removed':e.phase==='accepted'?'accepted':e.payload===undefined?'unknown':'pending':'unknown'};})};
|
|
38
|
+
return {...base,...(typeof running==='boolean'?{turnInProgress:running}:{}),sessionId:sid,pendingQueue:pending,steeringQueueClientIds:[...steering],inputDeliveryVersion:1,clearBoundaryMs:store.boundary(sid),deliveryReceipts:ids.map(clientId=>{const e=store.get(sid,clientId);return{clientId,state:e?e.phase==='removed'?'removed':e.phase==='accepted'?'accepted':e.payload===undefined?'unknown':'pending':'unknown',...(e?.removalReason?{reason:e.removalReason}:{})};})};
|
|
39
39
|
}
|
|
40
40
|
async function evidence(sid){
|
|
41
41
|
const proof=await backend.evidence(sid);
|
|
@@ -85,7 +85,16 @@ export function createInputDeliveryCoordinator({store,backend,baseProjection=()=
|
|
|
85
85
|
proof=await evidence(sid);
|
|
86
86
|
const settled=store.get(sid,current.clientId);
|
|
87
87
|
if(!terminal(settled.phase))save({...settled,phase:proof.inbox.some(item=>item.clientId===current.clientId)?'DSH-pending':'claimed',error:null});
|
|
88
|
-
}catch(error){const e=store.get(sid,current.clientId);
|
|
88
|
+
}catch(error){const e=store.get(sid,current.clientId);
|
|
89
|
+
if(error.deliverySuperseded||(error.deliveryNotAdmitted&&error.code==='PLAN_MODE_UNAVAILABLE')){
|
|
90
|
+
const checked=await evidence(sid);const latest=store.get(sid,current.clientId);
|
|
91
|
+
if(latest.phase==='accepted')continue;
|
|
92
|
+
// Fresh native ownership wins over this attempt's early refusal. Never
|
|
93
|
+
// roll DSH-pending back to stored and accidentally permit re-admission.
|
|
94
|
+
if(checked.inbox.some(item=>item.clientId===current.clientId)){notify(sid);onError(error);break;}
|
|
95
|
+
save({...latest,phase:'removed',payload:null,error:error.code,removalReason:error.deliverySuperseded?'continuation-superseded':'continuation-plan-mode-unavailable'});notify(sid);continue;
|
|
96
|
+
}
|
|
97
|
+
if(!terminal(e.phase))save({...e,...(error.deliveryNotAdmitted?{phase:'stored',...(error.deliveryRetryMode?{payload:{...e.payload,mode:error.deliveryRetryMode}}:{}),nextAttemptAt:now()+Math.min(30000,1000*2**Math.min(e.attemptNo??1,5))}:{}),error:String(error?.code??'DELIVERY_ADMISSION_UNCERTAIN')});onError(error);if(error.deliveryNotAdmitted){notify(sid);break;}}
|
|
89
98
|
notify(sid);
|
|
90
99
|
}
|
|
91
100
|
}
|
|
@@ -115,14 +124,24 @@ export function createInputDeliveryCoordinator({store,backend,baseProjection=()=
|
|
|
115
124
|
if(row.durableDelivery===true && !hasBoundary)throw deliveryError('INVALID_PARAMS','Durable input requires expectedClearBoundaryMs, including explicit null');
|
|
116
125
|
const expectedBoundary=hasBoundary?options.expectedClearBoundaryMs:store.boundary(sid);
|
|
117
126
|
if(!e && expectedBoundary!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','REMOTE_OPTIMISTIC_INPUT_CLEARED: input clear boundary changed');
|
|
118
|
-
const
|
|
127
|
+
const refuseContinuation = reason => {
|
|
128
|
+
const existing=store.get(sid,row.clientId);
|
|
129
|
+
if(existing?.phase==='removed')return view(sid,[row.clientId]);
|
|
130
|
+
if(existing)throw deliveryError('INPUT_DELIVERY_CONFLICT','clientId already belongs to an existing input');
|
|
131
|
+
save({sessionId:sid,clientId:row.clientId,phase:'removed',request:requestShape(row),rawFingerprint:raw,payload:null,queued:null,clearBoundaryMs:store.boundary(sid),createdAt:now(),attemptNo:0,cancelRequested:true,removalReason:reason});
|
|
132
|
+
notify(sid);return view(sid,[row.clientId]);
|
|
133
|
+
};
|
|
134
|
+
let prepared;
|
|
135
|
+
try{prepared=await backend.prepare(sid,row,options);}catch(error){if(error.code==='CONTINUATION_SUPERSEDED')return refuseContinuation('continuation-superseded');throw error;}
|
|
119
136
|
if(!prepared || prepared.payload===undefined || !prepared.queued)throw deliveryError('DELIVERY_PREPARATION_FAILED','Host did not obtain complete replayable input');
|
|
120
137
|
const {consumed: _transit,mode: _mode,...semanticPayload}=prepared.payload;const fingerprint=inputFingerprint(semanticPayload);e=store.get(sid,row.clientId);
|
|
121
138
|
if(e){if(e.phase==='removed')return view(sid,[row.clientId]);if(e.fingerprint!==fingerprint)throw deliveryError('INPUT_DELIVERY_CONFLICT','clientId already belongs to a different input');return view(sid,[row.clientId]);}
|
|
122
139
|
if(expectedBoundary!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','REMOTE_OPTIMISTIC_INPUT_CLEARED: input was cleared during preparation');
|
|
140
|
+
const origin=prepared.payload.continuation;
|
|
141
|
+
if(origin&&store.list(sid).some(other=>other.clientId!==row.clientId&&other.phase!=='removed'&&other.continuation?.tailId===origin.tailId))return refuseContinuation('continuation-already-submitted');
|
|
123
142
|
const preview=view(sid,[row.clientId]);preview.pendingQueue=[...preview.pendingQueue,prepared.queued];preview.deliveryReceipts=[{clientId:row.clientId,state:'pending'}];
|
|
124
143
|
if(!validateProjection(preview))throw deliveryError('DELIVERY_TOO_LARGE','Input queue projection cannot fit the negotiated transport budget');
|
|
125
|
-
save({sessionId:sid,clientId:row.clientId,phase:'stored',request:requestShape(row),rawFingerprint:raw,fingerprint,payload:prepared.payload,queued:prepared.queued,clearBoundaryMs:store.boundary(sid),sendAtMs:options.sendAtMs??null,createdAt:now(),attemptNo:0,cancelRequested:false});
|
|
144
|
+
save({sessionId:sid,clientId:row.clientId,phase:'stored',request:requestShape(row),rawFingerprint:raw,fingerprint,payload:prepared.payload,...(origin?{continuation:origin}:{}),queued:prepared.queued,clearBoundaryMs:store.boundary(sid),sendAtMs:options.sendAtMs??null,createdAt:now(),attemptNo:0,cancelRequested:false});
|
|
126
145
|
notify(sid);schedule(sid);return view(sid,[row.clientId]);
|
|
127
146
|
});
|
|
128
147
|
},
|
package/src/host-settings.js
CHANGED
|
@@ -5,7 +5,7 @@ export const SETTINGS_NAMESPACE = 'dsh-cindy-host';
|
|
|
5
5
|
// Controller-only delete/pin metadata and legacy archive flags survive restart.
|
|
6
6
|
// New archive/unarchive operations persist in DSH's native workspace registry;
|
|
7
7
|
// legacy archived flags are retained until explicitly retired (see `session-flags.js`).
|
|
8
|
-
export const DEFAULT_HOST_SETTINGS = Object.freeze({ transportEnabled: false, remoteControlEnabled: false, controllers: {}, deviceId: '', deviceName: '', sessionFlags: {} });
|
|
8
|
+
export const DEFAULT_HOST_SETTINGS = Object.freeze({ transportEnabled: false, remoteControlEnabled: false, controllers: {}, deviceId: '', deviceName: '', sessionFlags: {}, diagnosticPresentationV1: {} });
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The Host's own relay identity, when it has one.
|
|
@@ -46,6 +46,20 @@ export function validateHostSettings(value) {
|
|
|
46
46
|
}
|
|
47
47
|
// An absent section is valid — every settings file written before this field
|
|
48
48
|
// existed has none, and rejecting it would fail the load instead of the flag.
|
|
49
|
+
if (value.diagnosticPresentationV1 !== undefined) {
|
|
50
|
+
const record = entry => entry !== null && typeof entry === 'object' && !Array.isArray(entry);
|
|
51
|
+
const data=value.diagnosticPresentationV1;
|
|
52
|
+
if(!record(data))throw new Error('Invalid diagnostic presentation preferences');
|
|
53
|
+
for(const sessions of Object.values(data)){
|
|
54
|
+
if(!record(sessions))throw new Error('Invalid diagnostic owner preferences');
|
|
55
|
+
for(const kinds of Object.values(sessions)){
|
|
56
|
+
if(!record(kinds))throw new Error('Invalid diagnostic session preferences');
|
|
57
|
+
for(const [kind,ids] of Object.entries(kinds)){
|
|
58
|
+
if(!['errors','interruptions'].includes(kind)||!record(ids)||Object.values(ids).some(flag=>typeof flag!=='boolean'))throw new Error('Invalid diagnostic dismissal preferences');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
49
63
|
if (value.sessionFlags !== undefined) {
|
|
50
64
|
if (value.sessionFlags === null || typeof value.sessionFlags !== 'object') throw new Error('Invalid session flags');
|
|
51
65
|
for (const [sessionId, flags] of Object.entries(value.sessionFlags)) {
|
package/src/host-version.js
CHANGED
package/src/host.js
CHANGED
|
@@ -36,7 +36,8 @@ import { SessionReadModel } from './session-read-model.js';
|
|
|
36
36
|
import { ProjectionReadModelSink } from './projection-read-model-sink.js';
|
|
37
37
|
import { publishLifecycle, runningStatusEvent, turnEventFor } from './session-publisher.js';
|
|
38
38
|
import { createReasoningPublisher } from './reasoning-publisher.js';
|
|
39
|
-
import {
|
|
39
|
+
import { turnDiagnosticRow, turnDiagnosticPayload } from './turn-error.js';
|
|
40
|
+
import { advanceInterruptionMetadata, diagnosticOwner, interruptionFields, projectDismissedErrors } from './diagnostic-presentation.js';
|
|
40
41
|
import { acceptLink } from './host-authorization.js';
|
|
41
42
|
import { AuthorizationPolicy } from './authorization-policy.js';
|
|
42
43
|
import { HostStatus } from './host-status.js';
|
|
@@ -374,6 +375,69 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
374
375
|
let reminderObserverSource;
|
|
375
376
|
let reminderObserverPort;
|
|
376
377
|
const sessionMetaWrites = new Map();
|
|
378
|
+
const interruptionStates = new Map();
|
|
379
|
+
const nativePresentationMetadata = new Map();
|
|
380
|
+
const presentationData = () => options.diagnosticPreferences?.snapshot?.() ?? {};
|
|
381
|
+
const presentationOwner = () => diagnosticOwner(deliveryNamespace, options.inputDeliveryDirectory ?? '');
|
|
382
|
+
const presentationFields = (sid, running = false, candidate = interruptionStates.get(sid)) => interruptionFields(candidate, running, sessionIsHidden(sid), presentationData(), presentationOwner(), sid);
|
|
383
|
+
const projectMessages = rows => projectDismissedErrors(rows, presentationData(), presentationOwner());
|
|
384
|
+
|
|
385
|
+
async function refreshInterruption(sid, row) {
|
|
386
|
+
const owner = source; const revision = sourceRevision; const version = turnVersions.get(sid); const connection = generation;
|
|
387
|
+
const reader = options.resolveCapabilities?.()?.readMessages;
|
|
388
|
+
if (typeof reader?.turnState !== 'function') return;
|
|
389
|
+
const state = await reader.turnState(sid);
|
|
390
|
+
if (stopped || generation !== connection || source !== owner || sourceRevision !== revision || turnVersions.get(sid) !== version || options.resolveCapabilities?.()?.readMessages !== reader) throw Object.assign(new Error('Native session changed; synchronize again'), { code: 'NOT_AVAILABLE' });
|
|
391
|
+
let live = row;
|
|
392
|
+
if (typeof owner?.listSessionStates === 'function') {
|
|
393
|
+
const rows = await owner.listSessionStates(sid); live = rows.find(item => String(item.id) === sid);
|
|
394
|
+
}
|
|
395
|
+
if (stopped || generation !== connection || source !== owner || sourceRevision !== revision || turnVersions.get(sid) !== version || options.resolveCapabilities?.()?.readMessages !== reader || !live || typeof live.running !== 'boolean' || (nativeBoundaries.get(sid)?.seq ?? -1) > (state?.seq ?? -1)) throw Object.assign(new Error('Native session state is unavailable'), { code: 'NOT_AVAILABLE' });
|
|
396
|
+
if (Number.isSafeInteger(state?.boundarySeq)) nativeBoundaries.set(sid, { seq: state.boundarySeq, turn: state.nativeMetadata?.start?.data?.turn });
|
|
397
|
+
interruptionStates.set(sid, live.running || state?.running ? null : state?.interruption ?? null);
|
|
398
|
+
if (state?.nativeMetadata) nativePresentationMetadata.set(sid, state.nativeMetadata);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function diagnosticAction(sid, peer, clientId) {
|
|
402
|
+
const scopeOwner = source; const revision = sourceRevision; const owner = presentationOwner(); const connection = generation;
|
|
403
|
+
const grant = current?.controllers?.[peer]?.grantRevision;
|
|
404
|
+
const valid = () => !stopped && active && owner && presentationOwner() === owner && source === scopeOwner && sourceRevision === revision
|
|
405
|
+
&& generation === connection && acceptedControllers.has(peer) && policy.canAccept(peer) && !sessionIsHidden(sid)
|
|
406
|
+
&& current?.controllers?.[peer]?.grantRevision === grant;
|
|
407
|
+
const requireCurrent = () => { if (!valid()) throw Object.assign(new Error('Diagnostic action scope is unavailable'), { code: 'NOT_AVAILABLE' }); };
|
|
408
|
+
requireCurrent();
|
|
409
|
+
const row = await (typeof source.getSession === 'function' ? source.getSession(sid) : source.listSessions().then(rows => rows.find(row => String(row.id) === sid)));
|
|
410
|
+
requireCurrent(); if (!row) throw Object.assign(new Error('Session does not exist'), { code: 'NOT_FOUND' });
|
|
411
|
+
const preferences = options.diagnosticPreferences;
|
|
412
|
+
if (typeof preferences?.commit !== 'function') throw Object.assign(new Error('Durable diagnostic preferences are unavailable'), { code: 'NOT_AVAILABLE' });
|
|
413
|
+
if (clientId === undefined) {
|
|
414
|
+
await refreshInterruption(sid, row); requireCurrent();
|
|
415
|
+
const candidate = interruptionStates.get(sid);
|
|
416
|
+
if (!candidate) return { ok: true };
|
|
417
|
+
await preferences.commit({ owner, sessionId: sid, kind: 'interruptions', id: candidate.id }, valid);
|
|
418
|
+
requireCurrent();
|
|
419
|
+
// Re-read after persistence; an in-flight old acknowledgement never clears a new interruption.
|
|
420
|
+
await refreshInterruption(sid, row); requireCurrent();
|
|
421
|
+
publishRowPatch(sid, presentationFields(sid, isSessionRunningNow(sid)));
|
|
422
|
+
routeInvoke.invalidateSessionRows();
|
|
423
|
+
return { ok: true };
|
|
424
|
+
}
|
|
425
|
+
const reader = options.resolveCapabilities?.()?.readAllMessages;
|
|
426
|
+
if (typeof reader !== 'function') throw Object.assign(new Error('Committed message history is unavailable'), { code: 'NOT_AVAILABLE' });
|
|
427
|
+
const rows = await reader(sid); requireCurrent();
|
|
428
|
+
const prefix = `${sid}:turn-error:`;
|
|
429
|
+
const message = rows.find(item => {
|
|
430
|
+
if (item.sessionId !== sid || item.clientId !== clientId || item.id !== clientId || item.role !== 'error' || typeof item.id !== 'string' || !item.id.startsWith(prefix)) return false;
|
|
431
|
+
const seq = Number(item.id.slice(prefix.length));
|
|
432
|
+
return Number.isSafeInteger(seq) && seq >= 0 && item.id === `${prefix}${seq}`;
|
|
433
|
+
});
|
|
434
|
+
if (!message) throw Object.assign(new Error('Host error message does not exist'), { code: 'NOT_FOUND' });
|
|
435
|
+
await preferences.commit({ owner, sessionId: sid, kind: 'errors', id: clientId }, valid); requireCurrent();
|
|
436
|
+
const [effective] = projectMessages([message]);
|
|
437
|
+
pushSessionUpdate(sid, 'local-db:messages:created', { sessionId: sid, message: effective });
|
|
438
|
+
pushHistoryViewChanged(sid);
|
|
439
|
+
return effective;
|
|
440
|
+
}
|
|
377
441
|
function serializeSessionMeta(sessionId, operation) {
|
|
378
442
|
const pending = (sessionMetaWrites.get(sessionId) ?? Promise.resolve()).then(operation);
|
|
379
443
|
const settled = pending.then(() => {}, () => {});
|
|
@@ -384,7 +448,14 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
384
448
|
function projectSessionRows(rows) {
|
|
385
449
|
const archived = source?.archive?.snapshot(); // Read the native set once per page, not once per row.
|
|
386
450
|
const pinned = source?.archive?.pinSnapshot?.();
|
|
387
|
-
|
|
451
|
+
// Native settings describe can traverse every plugin: read once per page.
|
|
452
|
+
const data = presentationData(); const owner = presentationOwner();
|
|
453
|
+
const nativeReader = typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function';
|
|
454
|
+
return sessionFlags.projectAll(rows).map(original => {
|
|
455
|
+
const sid = String(original.id);
|
|
456
|
+
const native = nativeReader || Object.hasOwn(original, 'interruption');
|
|
457
|
+
const candidate = interruptionStates.has(sid) ? interruptionStates.get(sid) : original.interruption;
|
|
458
|
+
const row = native ? { ...original, ...interruptionFields(candidate, original.running === true || isSessionRunningNow(sid), sessionIsHidden(sid), data, owner, sid) } : original;
|
|
388
459
|
if (archived?.has(String(row.id)) === true) {
|
|
389
460
|
return { ...row, status: row.status === 'deleted' ? 'deleted' : 'archived', pinnedAt: null };
|
|
390
461
|
}
|
|
@@ -761,6 +832,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
761
832
|
if (source !== next) {
|
|
762
833
|
sourceRevision++;
|
|
763
834
|
nativeBoundaries.clear();
|
|
835
|
+
interruptionStates.clear();
|
|
836
|
+
nativePresentationMetadata.clear();
|
|
764
837
|
turnVersions.clear();
|
|
765
838
|
terminalIdentities.clear();
|
|
766
839
|
source?.manualCompact?.cancelAll('DSH source replaced');
|
|
@@ -925,12 +998,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
925
998
|
}
|
|
926
999
|
|
|
927
1000
|
/** Send a native terminal identity once per authorized subscription/connection. */
|
|
928
|
-
function sendNativeTerminal(deviceId, sessionId, payload, identity) {
|
|
1001
|
+
function sendNativeTerminal(deviceId, sessionId, payload, identity, row) {
|
|
929
1002
|
if (stopped || !active || !acceptedControllers.has(deviceId) || !policy.canAccept(deviceId) || sessionIsHidden(sessionId)
|
|
930
1003
|
|| !sessionSubscribers.get(sessionId)?.has(deviceId)) return false;
|
|
931
1004
|
const key = terminalAnnounceKey(deviceId, sessionId);
|
|
932
1005
|
const stamp = `${generation}:${subscriptionLeases.get(key) ?? 0}:${identity}`;
|
|
933
1006
|
if (terminalIdentities.get(key) === stamp) return false;
|
|
1007
|
+
// Reconnect must restore the actual diagnostic, not merely stop a spinner.
|
|
1008
|
+
if (row) {
|
|
1009
|
+
const notice = { sessionId, message: row };
|
|
1010
|
+
recordPush(sessionId, 'local-db:messages:created', offlineDevices.has(deviceId) ? 0 : 1, notice);
|
|
1011
|
+
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'local-db:messages:created', payload: notice } });
|
|
1012
|
+
}
|
|
934
1013
|
recordPush(sessionId, 'maker:event', offlineDevices.has(deviceId) ? 0 : 1, payload);
|
|
935
1014
|
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
|
|
936
1015
|
if (!offlineDevices.has(deviceId)) {
|
|
@@ -976,9 +1055,12 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
976
1055
|
live = Array.isArray(rows) ? rows.find(row => String(row?.id) === sessionId) : null;
|
|
977
1056
|
} else if (typeof owner?.getSession === 'function') live = await owner.getSession(sessionId);
|
|
978
1057
|
if (!valid() || !live || typeof live.running !== 'boolean') return;
|
|
1058
|
+
interruptionStates.set(sessionId, live.running || state.running ? null : state.interruption ?? null);
|
|
1059
|
+
if (state.nativeMetadata) nativePresentationMetadata.set(sessionId, state.nativeMetadata);
|
|
1060
|
+
publishRowPatch(sessionId, presentationFields(sessionId, live.running));
|
|
979
1061
|
if (live.running) {
|
|
980
1062
|
setCachedRunning(sessionId, true);
|
|
981
|
-
const payload = { sessionId, event: runningStatusEvent() };
|
|
1063
|
+
const payload = { sessionId, event: runningStatusEvent(state.retryNotice?.content ?? 'running') };
|
|
982
1064
|
recordPush(sessionId, 'maker:event', 1, payload);
|
|
983
1065
|
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
|
|
984
1066
|
return;
|
|
@@ -987,8 +1069,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
987
1069
|
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) > state.boundarySeq) return;
|
|
988
1070
|
// No await after the final guard: a new boundary cannot interleave with send.
|
|
989
1071
|
setCachedRunning(sessionId, false);
|
|
990
|
-
const
|
|
991
|
-
|
|
1072
|
+
const diagnostic = state.error ?? state.notice;
|
|
1073
|
+
const [effective] = diagnostic ? projectMessages([diagnostic]) : [];
|
|
1074
|
+
const payload = effective ? turnDiagnosticPayload(effective) : { sessionId, event: { type: 'done' } };
|
|
1075
|
+
sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`, effective);
|
|
992
1076
|
} catch {
|
|
993
1077
|
if (!valid()) return;
|
|
994
1078
|
// Never reflect arbitrary upstream exception prose into remote diagnostics.
|
|
@@ -1363,7 +1447,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1363
1447
|
*/
|
|
1364
1448
|
function pushSessionUpdate(sessionId, channel, payload) {
|
|
1365
1449
|
if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
|
|
1366
|
-
const guarded =
|
|
1450
|
+
const guarded = channel === 'local-db:messages:created' || channel === 'maker:event';
|
|
1367
1451
|
const devices = guarded
|
|
1368
1452
|
? new Set([...(sessionSubscribers.get(sessionId) ?? [])].filter(id => !sessionIsHidden(sessionId) && acceptedControllers.has(id) && policy.canAccept(id)))
|
|
1369
1453
|
: sessionSubscribers.get(sessionId);
|
|
@@ -1437,6 +1521,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1437
1521
|
* chip instead of the picture.
|
|
1438
1522
|
*/
|
|
1439
1523
|
async function pushSessionMessage(sessionId, message) {
|
|
1524
|
+
[message] = projectMessages([message]);
|
|
1440
1525
|
if (message?.role === 'user' && activeDelivery?.owns(sessionId, message.clientId)) {
|
|
1441
1526
|
await new Promise(resolve => queueMicrotask(resolve));
|
|
1442
1527
|
const proof = await activeDelivery.projection(sessionId, { deliveryClientIds: [message.clientId] });
|
|
@@ -1450,7 +1535,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1450
1535
|
// nothing, which is the same class of failure as the bug being fixed.)
|
|
1451
1536
|
const provider = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : undefined;
|
|
1452
1537
|
const hydrate = provider?.hydrateImages;
|
|
1453
|
-
if (message?.role
|
|
1538
|
+
if (!['error', 'system'].includes(message?.role) && typeof hydrate === 'function') await hydrate([message]);
|
|
1454
1539
|
} catch {
|
|
1455
1540
|
// Un-hydrated is still worth sending.
|
|
1456
1541
|
}
|
|
@@ -1539,6 +1624,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1539
1624
|
? await source.getSession(sessionId)
|
|
1540
1625
|
: (await source.listSessions()).find((candidate) => String(candidate.id) === sessionId);
|
|
1541
1626
|
if (row == null) return undefined;
|
|
1627
|
+
await refreshInterruption(sessionId, row);
|
|
1542
1628
|
const [flagged] = projectSessionRows([row]);
|
|
1543
1629
|
if (flagged !== undefined) sessionRowCache.set(String(flagged.id), flagged);
|
|
1544
1630
|
return flagged;
|
|
@@ -1685,6 +1771,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1685
1771
|
// the phone reaches any other screen watching the same session.
|
|
1686
1772
|
pushGoalStatus: (sessionId, status) => pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: status }),
|
|
1687
1773
|
...provided,
|
|
1774
|
+
ackInterrupted: (sid, peer) => diagnosticAction(sid, peer),
|
|
1775
|
+
dismissError: (sid, cid, peer) => diagnosticAction(sid, peer, cid),
|
|
1776
|
+
...(typeof provided.readMessages === 'function' ? { readMessages: async (...args) => projectMessages(await provided.readMessages(...args)) } : {}),
|
|
1777
|
+
...(typeof provided.readAllMessages === 'function' ? { readAllMessages: async (...args) => projectMessages(await provided.readAllMessages(...args)) } : {}),
|
|
1688
1778
|
/**
|
|
1689
1779
|
* The plugin's send seam, carrying this Host's media access.
|
|
1690
1780
|
*
|
|
@@ -1816,18 +1906,28 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1816
1906
|
for (const deviceId of sessionSubscribers.get(sessionId) ?? []) void restoreWatchedTurnState(deviceId, sessionId);
|
|
1817
1907
|
return;
|
|
1818
1908
|
}
|
|
1819
|
-
const
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1909
|
+
const rawDiagnostic = turnDiagnosticRow(event, sessionId);
|
|
1910
|
+
const diagnostic = rawDiagnostic ? projectMessages([rawDiagnostic])[0] : null;
|
|
1911
|
+
const requiresNotice = diagnostic !== null || ['error', 'interrupted', 'max-tokens', 'aborted', 'blocked'].includes(event?.data?.reason?.kind);
|
|
1912
|
+
if (requiresNotice && !diagnostic) {
|
|
1822
1913
|
recordHandlerError('turn-error', new Error('Invalid native failed-turn identity; history resynchronization required'));
|
|
1823
1914
|
pushHistoryViewChanged(sessionId);
|
|
1824
1915
|
return;
|
|
1825
1916
|
}
|
|
1826
1917
|
if (event && Number.isSafeInteger(event.seq)) {
|
|
1827
1918
|
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
|
|
1828
|
-
nativeBoundaries.set(sessionId, { seq: event.seq });
|
|
1919
|
+
nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
|
|
1829
1920
|
}
|
|
1830
1921
|
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
1922
|
+
if (event) {
|
|
1923
|
+
const meta = advanceInterruptionMetadata(nativePresentationMetadata.get(sessionId), event, sessionId);
|
|
1924
|
+
nativePresentationMetadata.set(sessionId, meta); interruptionStates.set(sessionId, meta.interruption);
|
|
1925
|
+
if (event.data?.reason?.kind === 'interrupted' && !meta.start) {
|
|
1926
|
+
interruptionStates.delete(sessionId);
|
|
1927
|
+
void refreshInterruption(sessionId, sessionRowFor(sessionId)).then(() => publishRowPatch(sessionId, presentationFields(sessionId, isSessionRunningNow(sessionId))))
|
|
1928
|
+
.catch(error => recordHandlerError('interruption-metadata', error));
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1831
1931
|
const timer = pendingReconciles.get(sessionId);
|
|
1832
1932
|
if (timer !== undefined) {
|
|
1833
1933
|
pendingReconciles.delete(sessionId);
|
|
@@ -1839,8 +1939,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1839
1939
|
// tells it first — and the re-link repair depends on that record meaning "it arrived", so a
|
|
1840
1940
|
// device the relay calls unreachable must not be written into it.
|
|
1841
1941
|
const watchers = [...(sessionSubscribers.get(sessionId) ?? [])];
|
|
1842
|
-
if (
|
|
1843
|
-
const payload =
|
|
1942
|
+
if (requiresNotice) {
|
|
1943
|
+
const payload = turnDiagnosticPayload(diagnostic);
|
|
1844
1944
|
for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, payload, `end:${event.seq}`);
|
|
1845
1945
|
// Error detail never goes to list subscribers or unwatched controllers.
|
|
1846
1946
|
publishRowTurnState(sessionId, false);
|
|
@@ -1905,7 +2005,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1905
2005
|
* @param running - the turn state DSH just reported.
|
|
1906
2006
|
*/
|
|
1907
2007
|
function publishRowTurnState(sessionId, running) {
|
|
1908
|
-
publishRowPatch(sessionId, { running, updatedAt: now().toISOString() });
|
|
2008
|
+
publishRowPatch(sessionId, { running, ...presentationFields(sessionId, running), updatedAt: now().toISOString() });
|
|
1909
2009
|
}
|
|
1910
2010
|
|
|
1911
2011
|
/**
|
|
@@ -1924,7 +2024,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1924
2024
|
for (const deviceId of sessionSubscribers.get(sessionId) ?? []) devices.add(deviceId);
|
|
1925
2025
|
recordPush(sessionId, 'local-db:sessions:patched', devices.size, payload);
|
|
1926
2026
|
if (devices.size === 0) return;
|
|
1927
|
-
for (const dst of devices) send({ v: PROTOCOL_VERSION, kind: 'push', dst, payload: { channel: 'local-db:sessions:patched', payload } });
|
|
2027
|
+
for (const dst of devices) if (acceptedControllers.has(dst) && policy.canAccept(dst)) send({ v: PROTOCOL_VERSION, kind: 'push', dst, payload: { channel: 'local-db:sessions:patched', payload } });
|
|
1928
2028
|
}
|
|
1929
2029
|
|
|
1930
2030
|
/**
|
|
@@ -2000,9 +2100,11 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2000
2100
|
function announceTurnRunning(sessionId, event) {
|
|
2001
2101
|
if (event && Number.isSafeInteger(event.seq)) {
|
|
2002
2102
|
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
|
|
2003
|
-
nativeBoundaries.set(sessionId, { seq: event.seq });
|
|
2103
|
+
nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
|
|
2004
2104
|
}
|
|
2005
2105
|
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
2106
|
+
interruptionStates.set(sessionId, null);
|
|
2107
|
+
if (event) nativePresentationMetadata.set(sessionId, advanceInterruptionMetadata(nativePresentationMetadata.get(sessionId), event, sessionId));
|
|
2006
2108
|
setCachedRunning(sessionId, true);
|
|
2007
2109
|
pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent() });
|
|
2008
2110
|
publishRowTurnState(sessionId, true);
|
|
@@ -2644,6 +2746,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2644
2746
|
sessionSubscribers.clear();
|
|
2645
2747
|
liveTurnState.clear();
|
|
2646
2748
|
nativeBoundaries.clear();
|
|
2749
|
+
interruptionStates.clear();
|
|
2750
|
+
nativePresentationMetadata.clear();
|
|
2647
2751
|
turnVersions.clear();
|
|
2648
2752
|
terminalIdentities.clear();
|
|
2649
2753
|
subscriptionLeases.clear();
|
|
@@ -2844,7 +2948,20 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2844
2948
|
}),
|
|
2845
2949
|
/** Push one live session message to the controllers watching that session. */
|
|
2846
2950
|
pushSessionMessage,
|
|
2951
|
+
projectMessages,
|
|
2847
2952
|
pushSessionReasoning: reasoningPublisher.noteEvent,
|
|
2953
|
+
// Only explicit native scheduled/started retry events assert ongoing work.
|
|
2954
|
+
// Failed attempts alone never terminate or start a turn.
|
|
2955
|
+
pushSessionDiagnostic: (sessionId, event) => {
|
|
2956
|
+
if (!['llm/retry', 'llm/retry-started'].includes(event?.type)) return;
|
|
2957
|
+
const row = turnDiagnosticRow(event, sessionId);
|
|
2958
|
+
const boundary = nativeBoundaries.get(sessionId);
|
|
2959
|
+
if (!row || (boundary?.seq ?? -1) >= event.seq
|
|
2960
|
+
|| (boundary?.turn !== undefined && boundary.turn !== event.data?.turn)) return;
|
|
2961
|
+
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
2962
|
+
setCachedRunning(sessionId, true);
|
|
2963
|
+
pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent(row.content) });
|
|
2964
|
+
},
|
|
2848
2965
|
/**
|
|
2849
2966
|
* Announce a real turn boundary to a session's watchers.
|
|
2850
2967
|
*
|
package/src/turn-error.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
/** Display-only
|
|
1
|
+
/** Display-only native turn diagnostics. DSH owns failure, recovery and retry policy. */
|
|
2
|
+
import { advanceInterruptionMetadata } from './diagnostic-presentation.js';
|
|
2
3
|
const SAFE_MESSAGES = new Map([
|
|
3
4
|
['AUTH', 'Model authentication failed. Check the provider configuration.'],
|
|
5
|
+
['ACCOUNT_SIGNED_OUT', '任务已因退出 DeepSeek 登录而停止。'],
|
|
6
|
+
['ACCOUNT_SIGN_IN_REQUIRED', '请先登录 DeepSeek,并确认请求地址支持账号认证。'],
|
|
4
7
|
['MISSING_CREDENTIAL', 'Model credentials are missing. Check the provider configuration.'],
|
|
5
8
|
['INVALID_CREDENTIAL', 'Model credentials are invalid. Check the provider configuration.'],
|
|
6
9
|
['RATE_LIMIT', 'The model request was rate limited.'],
|
|
@@ -20,16 +23,20 @@ export function turnErrorRow(event, sessionId) {
|
|
|
20
23
|
if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(event.seq) || event.seq < 0) return null;
|
|
21
24
|
// No wall-clock fallback: cold replay and live delivery must produce identical rows.
|
|
22
25
|
if (!Number.isFinite(event.time) || !Number.isFinite(new Date(event.time).getTime())) return null;
|
|
23
|
-
const
|
|
26
|
+
const content = safeFailureContent(event.data.reason.error);
|
|
27
|
+
const id = `${sessionId}:turn-error:${event.seq}`;
|
|
28
|
+
return { id, clientId: id, sessionId, role: 'error', toolUseId: null, agentMeta: null,
|
|
29
|
+
createdAt: new Date(event.time).toISOString(), content };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One safe allowlist for terminal model failures and scheduled retry details. */
|
|
33
|
+
export function safeFailureContent(failure) {
|
|
24
34
|
const code = SAFE_MESSAGES.has(failure?.code) ? failure.code : null;
|
|
25
35
|
const status = Number.isInteger(failure?.status) && failure.status >= 100 && failure.status <= 599 ? failure.status : null;
|
|
26
36
|
const detail = [code, status === null ? null : `HTTP ${status}`].filter(Boolean).join('; ');
|
|
27
37
|
const message = (SAFE_MESSAGES.get(code) ?? 'This turn failed. See the local DSH diagnostics for details.')
|
|
28
38
|
+ (detail ? ` (${detail})` : '');
|
|
29
|
-
|
|
30
|
-
return { id, clientId: id, sessionId, role: 'error', toolUseId: null, agentMeta: null,
|
|
31
|
-
createdAt: new Date(event.time).toISOString(),
|
|
32
|
-
content: { message, reason: 'dsh-turn-error', ...(code ? { code } : {}), ...(status === null ? {} : { status }) } };
|
|
39
|
+
return { message, reason: 'dsh-turn-error', ...(code ? { code } : {}), ...(status === null ? {} : { status }) };
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/** The maker envelope and the history row share one durable identity. */
|
|
@@ -38,10 +45,65 @@ export function turnErrorPayload(row) {
|
|
|
38
45
|
data: { ...row.content, isTerminal: true, willRetry: false } } };
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/** User-visible desktop turn tails, kept separate from model failures. Never
|
|
49
|
+
* infer an interruption from an ordinary aborted turn or an arbitrary string. */
|
|
50
|
+
export function turnDiagnosticRow(event, sessionId) {
|
|
51
|
+
const failure = turnErrorRow(event, sessionId);
|
|
52
|
+
if (failure) return failure;
|
|
53
|
+
if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || !Number.isFinite(event.time)
|
|
54
|
+
|| !Number.isFinite(new Date(event.time).getTime()) || typeof sessionId !== 'string' || !sessionId) return null;
|
|
55
|
+
let content;
|
|
56
|
+
const data = event.data;
|
|
57
|
+
if (event.type === 'turn/end') {
|
|
58
|
+
const reason = data?.reason;
|
|
59
|
+
switch (reason?.kind) {
|
|
60
|
+
case 'interrupted':
|
|
61
|
+
content = '任务已停止:DSH 进程在本轮完成前退出(例如被强制结束、崩溃或关闭超时),重新启动后本轮已中止。未完成的工具调用结果未知。';
|
|
62
|
+
break;
|
|
63
|
+
case 'max-tokens':
|
|
64
|
+
content = '已达到输出 token 上限。回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。';
|
|
65
|
+
break;
|
|
66
|
+
case 'blocked': content = '本轮执行被阻止,未正常完成。原生结束事件未提供更多原因。'; break;
|
|
67
|
+
case 'aborted':
|
|
68
|
+
content = reason.reason?.kind === 'hook' && reason.reason.reason === 'deepseek-account/signed-out'
|
|
69
|
+
? '任务已因退出 DeepSeek 登录而停止。'
|
|
70
|
+
: reason.reason?.kind === 'user' ? '任务已停止:用户取消了本轮执行。'
|
|
71
|
+
: reason.reason?.kind === 'parent' ? '任务已停止:上级任务取消了本轮执行。'
|
|
72
|
+
: reason.reason?.kind === 'disposed' ? '任务已停止:本轮运行实例已被释放。'
|
|
73
|
+
: '任务已停止:本轮执行已被取消。';
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
if (!reason || typeof reason.kind !== 'string' || ['completed', 'forked'].includes(reason.kind)) return null;
|
|
77
|
+
content = '本轮已结束。当前 Host 尚不能显示此结束原因的详细信息,请在 DSH 桌面查看。';
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
} else if (event.type === 'llm/retry') {
|
|
81
|
+
if (!['normal', 'always'].includes(data?.mode) || !Number.isSafeInteger(data?.retry) || data.retry < 1 || !Number.isFinite(data.delayMs) || data.delayMs < 0) return null;
|
|
82
|
+
const maximum = data.mode === 'always' ? '不限' : Number.isSafeInteger(data.maxRetries) && data.maxRetries >= data.retry ? data.maxRetries : null;
|
|
83
|
+
if (maximum === null) return null;
|
|
84
|
+
content = `模型请求失败,DSH 已安排重试(${data.retry}/${maximum}),等待 ${Math.max(1, Math.ceil(data.delayMs / 1000))} 秒。${safeFailureContent(data.failure).message}`;
|
|
85
|
+
} else if (event.type === 'llm/retry-started') {
|
|
86
|
+
if (!Number.isSafeInteger(data?.retry) || data.retry < 1) return null;
|
|
87
|
+
content = `DSH 已开始第 ${data.retry} 次模型请求重试。`;
|
|
88
|
+
} else return null;
|
|
89
|
+
const id = `${sessionId}:turn-notice:${event.seq}`;
|
|
90
|
+
return { id, clientId: id, sessionId, role: 'system', toolUseId: null, agentMeta: null,
|
|
91
|
+
createdAt: new Date(event.time).toISOString(), content };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Notices close the running flag without pretending cancellation is a model failure. */
|
|
95
|
+
export function turnDiagnosticPayload(row) {
|
|
96
|
+
if (row.role === 'error' && row.content?.dismissed === true) return { sessionId: row.sessionId, persistId: row.clientId, event: { type: 'done', data: { reason: 'dsh-dismissed-error' } } };
|
|
97
|
+
return row.role === 'error' ? turnErrorPayload(row) : { sessionId: row.sessionId, persistId: row.clientId,
|
|
98
|
+
event: { type: 'done', data: { message: row.content, reason: 'dsh-turn-notice' } } };
|
|
99
|
+
}
|
|
100
|
+
|
|
41
101
|
/** Latest native turn boundary, not the latest error in an arbitrary history page. */
|
|
42
102
|
export function turnStateFromEvents(events, sessionId) {
|
|
43
103
|
if (!Array.isArray(events)) throw new TypeError('Native turn recovery requires a session event snapshot');
|
|
44
104
|
let boundary = null;
|
|
105
|
+
let nativeMetadata = null;
|
|
106
|
+
let retryNotice = null;
|
|
45
107
|
let seq = -1;
|
|
46
108
|
for (const event of events) {
|
|
47
109
|
if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || event.seq <= seq
|
|
@@ -49,12 +111,18 @@ export function turnStateFromEvents(events, sessionId) {
|
|
|
49
111
|
throw new TypeError('Native turn recovery requires ordered unique event sequences');
|
|
50
112
|
}
|
|
51
113
|
seq = event.seq;
|
|
52
|
-
|
|
114
|
+
nativeMetadata = advanceInterruptionMetadata(nativeMetadata, event, sessionId);
|
|
115
|
+
if (event.type === 'turn/start' || event.type === 'turn/end') { boundary = event; retryNotice = null; }
|
|
116
|
+
else if (['step/start', 'assistant/message'].includes(event.type)) retryNotice = null;
|
|
117
|
+
else if (boundary?.type === 'turn/start' && boundary.data?.turn === event.data?.turn
|
|
118
|
+
&& ['llm/retry', 'llm/retry-started'].includes(event.type)) retryNotice = turnDiagnosticRow(event, sessionId);
|
|
53
119
|
}
|
|
54
120
|
if (!boundary) return null;
|
|
55
|
-
if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null };
|
|
56
|
-
const
|
|
121
|
+
if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null, nativeMetadata, ...(retryNotice ? { retryNotice } : {}) };
|
|
122
|
+
const diagnostic = turnDiagnosticRow(boundary, sessionId);
|
|
123
|
+
const error = diagnostic?.role === 'error' ? diagnostic : null;
|
|
57
124
|
// A malformed failure is not evidence of a successful ending.
|
|
58
125
|
if (boundary.data?.reason?.kind === 'error' && !error) throw new TypeError('Invalid native failed-turn identity');
|
|
59
|
-
|
|
126
|
+
if (['interrupted', 'max-tokens', 'blocked', 'aborted'].includes(boundary.data?.reason?.kind) && !diagnostic) throw new TypeError('Invalid native turn-notice identity');
|
|
127
|
+
return { seq, boundarySeq: boundary.seq, running: false, error, nativeMetadata, interruption: nativeMetadata?.interruption ?? null, ...(diagnostic?.role === 'system' ? { notice: diagnostic } : {}) };
|
|
60
128
|
}
|