dsh-cindy-host 0.1.33 → 0.1.34
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 +7 -0
- package/README.md +16 -2
- package/package.json +1 -1
- package/src/cindy-channels.js +10 -6
- package/src/dsh-message-fold.js +23 -1
- package/src/dsh-plugin.js +40 -43
- package/src/host-approvals.js +37 -23
- package/src/host-version.js +1 -1
- package/src/host.js +181 -31
- package/src/session-publisher.js +3 -2
- package/src/turn-error.js +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.34 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
|
|
7
|
+
- Register user questions before either surface displays them, including sessions a phone has never visited. Replay the existing pending cards on session subscribe/reconnect; keep them through temporary relay recovery and remove the hidden Host question timeout, without changing permission ownership/deadlines.
|
|
8
|
+
- Settle local and phone answers through the same pending completion; preserve native cancellation/timeout errors and clear both cards on task stop instead of returning empty/undefined answers. Keep the existing Cindy protocol and DSH native UI. Add focused Host regressions and opt-in tests against installed Cordis/userQuestions/Gateway; these are not live-handset acceptance or a production deployment.
|
|
9
|
+
|
|
3
10
|
## 0.1.33 — 2026-11-04
|
|
4
11
|
|
|
5
12
|
- Add authorized current-surface reference-source history through native observations, real message identities, filters/cursors and explicit clipping metadata. Keep frozen target-reference capability false and reject structured contexts before every admission/edit/preparation/replay boundary; do not disguise source reads or plain links as target consumption.
|
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@ 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.34** improves native/phone question synchronization and cancellation, and safely displays durable model/turn failures. It retains 0.1.33's regional authentication UI, public 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.34
|
|
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.
|
|
@@ -99,6 +99,20 @@ During generation, `assistant/chunk` reasoning deltas are displayed as temporary
|
|
|
99
99
|
|
|
100
100
|
This ships in 0.1.33; handset acceptance is separate from package checks and local Host activation. Offline regressions execute the pinned real Cindy mobile thinking normalizer; live phone/network acceptance remains separate.
|
|
101
101
|
|
|
102
|
+
## Questions on DSH and Cindy
|
|
103
|
+
|
|
104
|
+
While the Host bridge is enabled, questions are registered before presentation, even if no phone has opened that session. DSH's native question UI and the existing Cindy card can both answer; the first accepted answer settles the shared pending entry and dismisses the other surface. Session open/reconnect replays pending cards with the same request IDs. Temporary relay loss does not cancel questions.
|
|
105
|
+
|
|
106
|
+
Questions have no extra Host timeout. The caller's stop/cancel and DSH's own timed-question lifecycle still apply; explicit Host switch-off/unload cancels outstanding bridge waits. Permission cards retain their existing ownership and deadline. No Cindy/DSH patch or phone protocol upgrade is needed. This does not add cross-process recovery or mobile late-answer support after DSH has continued a timed call.
|
|
107
|
+
|
|
108
|
+
Run the focused regressions with:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
node --test test/user-question-bridge.test.js test/host-approvals.test.js test/dsh-plugin.test.js
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The opt-in installed-runtime suite additionally exercises real Cordis, userQuestions and Gateway event delivery/replay/cancellation. Point `DSH_TEST_NATIVE_MODULES` at a built DSH node_modules directory, then run `node --test --test-isolation=none test/user-question-native.test.js`. For an Electron asar installation use the installed executable with `ELECTRON_RUN_AS_NODE=1`. These checks use isolated contexts and do not reload the running GUI; real-handset acceptance is separate.
|
|
115
|
+
|
|
102
116
|
## Model switching follows DSH
|
|
103
117
|
|
|
104
118
|
When native `sessionController.selectModel` is composed, the Host tells Cindy to delegate model-window decisions to DSH. Users may select another model during a running turn and select the original model again. DSH validates the provider/model/effort and records the selection for the **next model request**; an already-issued request is not interrupted. DSH owns context preparation and compaction, not Cindy's legacy 90%-window precheck or a Host-side summary/rebuild implementation.
|
package/package.json
CHANGED
package/src/cindy-channels.js
CHANGED
|
@@ -609,13 +609,17 @@ export function createChannelRouter({
|
|
|
609
609
|
}
|
|
610
610
|
}
|
|
611
611
|
for (const sessionId of sessionIds) {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
612
|
+
if (typeof subscribing.restoreTurnState === 'function') {
|
|
613
|
+
await subscribing.restoreTurnState(request.src, sessionId);
|
|
614
|
+
} else {
|
|
615
|
+
let row = live === null ? null : live.find((candidate) => String(candidate?.id) === sessionId) ?? null;
|
|
616
|
+
if (typeof getSession === 'function') {
|
|
617
|
+
try { row = await getSession(sessionId) ?? null; } catch { row = null; }
|
|
618
|
+
}
|
|
619
|
+
const running = row === null ? subscribing.isSessionRunning(sessionId) === true : row.running === true;
|
|
620
|
+
if (running) subscribing.pushTurnRunning(sessionId);
|
|
621
|
+
else subscribing.pushTurnIdle(sessionId);
|
|
615
622
|
}
|
|
616
|
-
const running = row === null ? subscribing.isSessionRunning(sessionId) === true : row.running === true;
|
|
617
|
-
if (running) subscribing.pushTurnRunning(sessionId);
|
|
618
|
-
else subscribing.pushTurnIdle(sessionId);
|
|
619
623
|
// …and the two things a push that never arrived would have carried.
|
|
620
624
|
//
|
|
621
625
|
// Measured: an app returning from the background does an unsubscribe → resubscribe
|
package/src/dsh-message-fold.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
|
|
19
19
|
import { createReasoningStream } from './reasoning-stream.js';
|
|
20
|
+
import { turnErrorRow, turnStateFromEvents } from './turn-error.js';
|
|
20
21
|
import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
|
|
21
22
|
|
|
22
23
|
/** Default page size when the controller does not ask for one. */
|
|
@@ -81,6 +82,8 @@ export const TRANSCRIPT_CACHE_SESSIONS = 3;
|
|
|
81
82
|
*/
|
|
82
83
|
export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
|
|
83
84
|
if (event === null || typeof event !== 'object') return [];
|
|
85
|
+
const error = turnErrorRow(event, sessionId);
|
|
86
|
+
if (error) return [error];
|
|
84
87
|
if (!isAppendSurfaceEvent(event)) return [];
|
|
85
88
|
let message;
|
|
86
89
|
try {
|
|
@@ -387,6 +390,11 @@ export function createMessageReader({
|
|
|
387
390
|
// gap as "nothing happened here".
|
|
388
391
|
if (Number.isFinite(event.seq)) lastSeq = event.seq;
|
|
389
392
|
reasoning.consume(event);
|
|
393
|
+
const error = turnErrorRow(event, sessionId);
|
|
394
|
+
if (error) {
|
|
395
|
+
entries.push({ rows: [error] });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
390
398
|
const todo = entryForTodoWrite(event);
|
|
391
399
|
if (todo !== null) {
|
|
392
400
|
entries.push(todo);
|
|
@@ -477,7 +485,15 @@ export function createMessageReader({
|
|
|
477
485
|
flight.promise = Promise.resolve().then(async () => {
|
|
478
486
|
try {
|
|
479
487
|
const read = await entriesFor(sessionId);
|
|
480
|
-
|
|
488
|
+
// Error rows are presentation, never fabricated model messages. Keep each
|
|
489
|
+
// message's block order while reversing event order, as toCindyMessages does.
|
|
490
|
+
const seen = new Set();
|
|
491
|
+
const rows = read.entries.slice().reverse().flatMap(entry => entry.rows
|
|
492
|
+
?? toCindyMessages([entry], { sessionId, now })).filter(row => {
|
|
493
|
+
if (seen.has(row.id)) return false;
|
|
494
|
+
seen.add(row.id);
|
|
495
|
+
return true;
|
|
496
|
+
});
|
|
481
497
|
// A live event during replay makes the snapshot unsafe to cache. Explicit
|
|
482
498
|
// invalidation also detaches this flight so a post-invalidation read starts
|
|
483
499
|
// fresh; an older completion must never overwrite that newer generation.
|
|
@@ -593,6 +609,12 @@ export function createMessageReader({
|
|
|
593
609
|
* The array is the cache's own, so callers treat it as read-only.
|
|
594
610
|
*/
|
|
595
611
|
readMessages.all = async (sessionId) => visibleRows(await ensureTranscript(sessionId));
|
|
612
|
+
// Recovery deliberately bypasses the transcript cache: stale history cannot
|
|
613
|
+
// decide whether the *current* turn has ended. Runtime generation guards the read.
|
|
614
|
+
readMessages.turnState = async (sessionId) => {
|
|
615
|
+
const snapshot = await readSessionLog(sessionId);
|
|
616
|
+
return turnStateFromEvents(snapshot?.events, sessionId);
|
|
617
|
+
};
|
|
596
618
|
readMessages.stats = stats;
|
|
597
619
|
return readMessages;
|
|
598
620
|
}
|
package/src/dsh-plugin.js
CHANGED
|
@@ -1685,48 +1685,43 @@ export function createSettingsScope(ctx, entryId, projected) {
|
|
|
1685
1685
|
|
|
1686
1686
|
|
|
1687
1687
|
/**
|
|
1688
|
-
*
|
|
1689
|
-
* The
|
|
1690
|
-
* without that cancellation contract a Promise.race would leak a parked waterfall listener.
|
|
1688
|
+
* Forward once to DSH and settle both surfaces through the existing registry.
|
|
1689
|
+
* The losing native card is dismissed through its private cancellation signal.
|
|
1691
1690
|
*/
|
|
1692
1691
|
export async function arbitrateInteraction({ request, next, begin, settleLocal }) {
|
|
1693
|
-
const handle = await begin();
|
|
1694
|
-
if (handle === null) return next();
|
|
1695
|
-
|
|
1696
1692
|
const downstreamAbort = new AbortController();
|
|
1697
1693
|
const upstreamSignal = request?.signal;
|
|
1698
1694
|
const downstreamSignal = upstreamSignal === undefined
|
|
1699
1695
|
? downstreamAbort.signal
|
|
1700
1696
|
: AbortSignal.any([upstreamSignal, downstreamAbort.signal]);
|
|
1701
|
-
let cancellable = false;
|
|
1702
1697
|
try {
|
|
1703
1698
|
request.signal = downstreamSignal;
|
|
1704
|
-
cancellable = request.signal === downstreamSignal;
|
|
1705
1699
|
} catch {
|
|
1706
1700
|
try {
|
|
1707
1701
|
Object.defineProperty(request, 'signal', { value: downstreamSignal, configurable: true, writable: true });
|
|
1708
|
-
cancellable = request.signal === downstreamSignal;
|
|
1709
1702
|
} catch {
|
|
1710
|
-
|
|
1703
|
+
// Without cancellation, use the original native path rather than a phone-only card.
|
|
1704
|
+
return next();
|
|
1711
1705
|
}
|
|
1712
1706
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1707
|
+
if (request.signal !== downstreamSignal) return next();
|
|
1708
|
+
const handle = await begin(upstreamSignal);
|
|
1709
|
+
if (handle === null) return next();
|
|
1715
1710
|
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
(
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1711
|
+
void Promise.resolve().then(next).then(
|
|
1712
|
+
(value) => settleLocal(handle.requestId, value),
|
|
1713
|
+
(error) => {
|
|
1714
|
+
// Native cancellation/timeout is a lifecycle result, not a failed delivery.
|
|
1715
|
+
// A missing/disconnected local answerer still leaves the phone usable.
|
|
1716
|
+
if (error?.name === 'UserQuestionError' && error.code !== 'NO_PROVIDER') handle.cancel?.(error);
|
|
1717
|
+
},
|
|
1718
|
+
).catch((error) => handle.cancel?.(error));
|
|
1719
|
+
try {
|
|
1720
|
+
// A rejected late local settlement cannot override an already accepted phone answer.
|
|
1721
|
+
return await handle.answered;
|
|
1722
|
+
} finally {
|
|
1723
|
+
downstreamAbort.abort(new Error('interaction settled'));
|
|
1726
1724
|
}
|
|
1727
|
-
downstreamAbort.abort(new Error('interaction settled by Cindy'));
|
|
1728
|
-
void local.then(() => undefined, () => undefined);
|
|
1729
|
-
return first.value;
|
|
1730
1725
|
}
|
|
1731
1726
|
|
|
1732
1727
|
export function apply(ctx, config) {
|
|
@@ -1784,27 +1779,30 @@ export function apply(ctx, config) {
|
|
|
1784
1779
|
}
|
|
1785
1780
|
|
|
1786
1781
|
/**
|
|
1787
|
-
*
|
|
1782
|
+
* Record failures; contain side effects but propagate awaited question failures.
|
|
1788
1783
|
* @param label - what this listener is, for the diagnostics record.
|
|
1789
1784
|
* @param listener - the listener as written.
|
|
1790
|
-
* @
|
|
1785
|
+
* @param options - rethrow only for awaited requests, never fire-and-forget events.
|
|
1786
|
+
* @returns the guarded listener.
|
|
1791
1787
|
*/
|
|
1792
|
-
function guarded(label, listener) {
|
|
1788
|
+
function guarded(label, listener, { rethrow = false } = {}) {
|
|
1793
1789
|
guardedListeners.push(label);
|
|
1794
1790
|
return (...args) => {
|
|
1795
1791
|
try {
|
|
1796
1792
|
const result = listener(...args);
|
|
1797
1793
|
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1794
|
+
// Awaited questions reach userQuestions.ask so cancellation keeps its
|
|
1795
|
+
// meaning; fire-and-forget side effects still contain their failures.
|
|
1800
1796
|
return result.catch((error) => {
|
|
1801
1797
|
noteBoundaryError(label, error);
|
|
1798
|
+
if (rethrow) throw error;
|
|
1802
1799
|
return undefined;
|
|
1803
1800
|
});
|
|
1804
1801
|
}
|
|
1805
1802
|
return result;
|
|
1806
1803
|
} catch (error) {
|
|
1807
1804
|
noteBoundaryError(label, error);
|
|
1805
|
+
if (rethrow) throw error;
|
|
1808
1806
|
return undefined;
|
|
1809
1807
|
}
|
|
1810
1808
|
};
|
|
@@ -2004,9 +2002,7 @@ export function apply(ctx, config) {
|
|
|
2004
2002
|
// stopped its timer while the agent was still generating. `turn/start` and
|
|
2005
2003
|
// `turn/end` are the boundaries that mean what the phone reads into them.
|
|
2006
2004
|
if (event?.type === 'turn/start' && typeof runtime.pushTurnRunning === 'function') {
|
|
2007
|
-
runtime.pushTurnRunning(sessionId);
|
|
2008
|
-
} else if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') {
|
|
2009
|
-
runtime.pushTurnIdle(sessionId);
|
|
2005
|
+
runtime.pushTurnRunning(sessionId, event);
|
|
2010
2006
|
}
|
|
2011
2007
|
// A real boundary needs no fallback, and arming one here is not harmless: this
|
|
2012
2008
|
// handler runs *after* the boundary was announced, so it re-armed the very
|
|
@@ -2034,6 +2030,9 @@ export function apply(ctx, config) {
|
|
|
2034
2030
|
currentSeam.readMessages.noteEvent(sessionId, event);
|
|
2035
2031
|
currentSource?.invalidateActivityEvidence?.(sessionId);
|
|
2036
2032
|
}
|
|
2033
|
+
// Make the failed history row readable before publishing its terminal identity.
|
|
2034
|
+
// Everything above is synchronous, so a new turn cannot interleave here.
|
|
2035
|
+
if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') runtime.pushTurnIdle(sessionId, event);
|
|
2037
2036
|
// Chunks are intentionally absent from deriveEventMessage. Publish a temporary
|
|
2038
2037
|
// thinking overlay too, retiring it before the durable assistant rows below.
|
|
2039
2038
|
runtime.pushSessionReasoning?.(sessionId, event, {
|
|
@@ -2065,12 +2064,12 @@ export function apply(ctx, config) {
|
|
|
2065
2064
|
return arbitrateInteraction({
|
|
2066
2065
|
request: req,
|
|
2067
2066
|
next,
|
|
2068
|
-
begin: () => runtime.beginApproval({
|
|
2067
|
+
begin: (signal) => runtime.beginApproval({
|
|
2069
2068
|
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
2070
2069
|
toolName: typeof req?.toolName === 'string' ? req.toolName : '',
|
|
2071
2070
|
reason: req?.reason,
|
|
2072
2071
|
callId: req?.callId,
|
|
2073
|
-
signal
|
|
2072
|
+
signal,
|
|
2074
2073
|
}),
|
|
2075
2074
|
settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
|
|
2076
2075
|
});
|
|
@@ -2092,28 +2091,26 @@ export function apply(ctx, config) {
|
|
|
2092
2091
|
// to the browser path unchanged.
|
|
2093
2092
|
}), { prepend: true, global: true }), 'dsh-cindy-host: approval answerer');
|
|
2094
2093
|
|
|
2095
|
-
//
|
|
2096
|
-
//
|
|
2097
|
-
//
|
|
2098
|
-
// the question. Same rule as approvals: only claim questions for a session a
|
|
2099
|
-
// controller is watching, and pass the rest down the chain.
|
|
2094
|
+
// Questions are registered even before a phone opens the session, then also
|
|
2095
|
+
// forwarded to DSH. Unlike side-effect listeners this awaited waterfall must
|
|
2096
|
+
// propagate cancellation instead of returning an undefined successful result.
|
|
2100
2097
|
ctx.effect(() => ctx.on('user-questions/request', guarded('user-questions-request', async (request, next) => {
|
|
2101
2098
|
if (!runtime || typeof runtime.beginUserQuestion !== 'function') return next();
|
|
2102
2099
|
const sessionId = request?.agent?.session?.header?.id;
|
|
2103
2100
|
return arbitrateInteraction({
|
|
2104
2101
|
request,
|
|
2105
2102
|
next,
|
|
2106
|
-
begin: () => runtime.beginUserQuestion({
|
|
2103
|
+
begin: (signal) => runtime.beginUserQuestion({
|
|
2107
2104
|
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
2108
2105
|
questions: request?.questions,
|
|
2109
|
-
signal
|
|
2106
|
+
signal,
|
|
2110
2107
|
}),
|
|
2111
2108
|
settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
|
|
2112
2109
|
});
|
|
2113
2110
|
// Same `prepend` + `global` requirement as the approval answerer above, and the
|
|
2114
2111
|
// same reason: this waterfall is agent-scoped and the Web bundle's remote
|
|
2115
2112
|
// forwarding listener parks ahead of a late registration.
|
|
2116
|
-
}), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
|
|
2113
|
+
}, { rethrow: true }), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
|
|
2117
2114
|
|
|
2118
2115
|
// The settings page reaches the Host through this route. `webServer` is absent
|
|
2119
2116
|
// in headless compositions, so the row is injected optionally: the runtime and
|
package/src/host-approvals.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Outcomes are DSH's own vocabulary (`'allowed-once' | 'rejected' | 'cancelled' |
|
|
17
17
|
* 'unavailable'`), not the controller's: `'allowed-once'` is the only grant DSH
|
|
18
|
-
* defines, so every non-allow answer maps to `'rejected'`.
|
|
18
|
+
* defines, so every non-allow answer maps to `'rejected'`. Structured questions
|
|
19
|
+
* are registered independently of watching and have no Host timeout; their
|
|
20
|
+
* caller's cancellation is propagated as a question failure.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
/** The controller's interaction kind for a permission question. */
|
|
@@ -145,17 +147,20 @@ export function createApprovalRegistry({
|
|
|
145
147
|
/**
|
|
146
148
|
* Register one pending interaction and hand back its lifetime controls.
|
|
147
149
|
*
|
|
148
|
-
* Both seams
|
|
149
|
-
*
|
|
150
|
-
* approval that can.
|
|
150
|
+
* Both seams share single settlement and dismissal. Permission waits have a
|
|
151
|
+
* deadline; structured questions follow the calling task's lifetime only.
|
|
151
152
|
*/
|
|
152
|
-
function register({ sessionId, request,
|
|
153
|
+
function register({ sessionId, request, signal, answerOf, waitMs = timeoutMs }) {
|
|
153
154
|
let settle;
|
|
154
|
-
|
|
155
|
+
let reject;
|
|
156
|
+
const answered = new Promise((resolve, fail) => {
|
|
155
157
|
settle = resolve;
|
|
158
|
+
reject = fail;
|
|
156
159
|
});
|
|
160
|
+
// The upstream signal may already be aborted before the async caller attaches.
|
|
161
|
+
void answered.catch(() => {});
|
|
157
162
|
|
|
158
|
-
const finish = (outcome) => {
|
|
163
|
+
const finish = (outcome, error) => {
|
|
159
164
|
const entry = pending.get(request.requestId);
|
|
160
165
|
if (entry === undefined) return;
|
|
161
166
|
pending.delete(request.requestId);
|
|
@@ -180,22 +185,31 @@ export function createApprovalRegistry({
|
|
|
180
185
|
kind: request.kind,
|
|
181
186
|
});
|
|
182
187
|
}
|
|
183
|
-
|
|
188
|
+
if (error !== undefined) reject(error);
|
|
189
|
+
else settle(outcome);
|
|
190
|
+
};
|
|
191
|
+
const cancel = (reason) => {
|
|
192
|
+
if (request.kind !== QUESTION_KIND) return finish(answerOf.cancelled);
|
|
193
|
+
const error = reason?.name === 'UserQuestionError' ? reason : Object.assign(
|
|
194
|
+
new Error('ask_user_question was cancelled', { cause: reason }),
|
|
195
|
+
{ name: 'UserQuestionError', code: 'ASK_ABORTED' },
|
|
196
|
+
);
|
|
197
|
+
finish(undefined, error);
|
|
184
198
|
};
|
|
185
199
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
const onAbort = signal === undefined ? null : () => finish(answerOf.cancelled);
|
|
200
|
+
// Withdrawals end the wait, but are never an empty successful answer.
|
|
201
|
+
const onAbort = signal === undefined ? null : () => cancel(signal.reason);
|
|
189
202
|
if (onAbort !== null) signal.addEventListener('abort', onAbort, { once: true });
|
|
190
203
|
|
|
191
|
-
const timer =
|
|
192
|
-
? setTimer(() =>
|
|
204
|
+
const timer = waitMs > 0
|
|
205
|
+
? setTimer(() => cancel(), waitMs)
|
|
193
206
|
: null;
|
|
194
207
|
if (typeof timer?.unref === 'function') timer.unref();
|
|
195
208
|
|
|
196
|
-
pending.set(request.requestId, { sessionId, request, settle: finish, timer, onAbort, answerOf });
|
|
197
|
-
if (
|
|
198
|
-
|
|
209
|
+
pending.set(request.requestId, { sessionId, request, settle: finish, cancel, timer, onAbort, answerOf });
|
|
210
|
+
if (signal?.aborted) cancel(signal.reason);
|
|
211
|
+
else if (request.kind === QUESTION_KIND) notifyQuestionObservers(questionRequestEvent(request));
|
|
212
|
+
return { requestId: request.requestId, request, answered, cancel };
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
/**
|
|
@@ -234,7 +248,7 @@ export function createApprovalRegistry({
|
|
|
234
248
|
* sees the card cannot answer it, and the conversation stops there.
|
|
235
249
|
*
|
|
236
250
|
* @param options - the questions, the asking session, and the caller's abort signal.
|
|
237
|
-
* @returns
|
|
251
|
+
* @returns A pending handle; cancellation rejects with DSH's question error vocabulary.
|
|
238
252
|
*/
|
|
239
253
|
async function askUser({ sessionId, questions, signal }) {
|
|
240
254
|
const items = Array.isArray(questions) ? questions : [];
|
|
@@ -247,13 +261,12 @@ export function createApprovalRegistry({
|
|
|
247
261
|
questions: items.map((question) => ({ ...question })),
|
|
248
262
|
createdAt: now().toISOString(),
|
|
249
263
|
};
|
|
250
|
-
const cancelled = { answers: items.map((question) => ({ id: String(question?.id ?? ''), selected: [] })) };
|
|
251
264
|
return register({
|
|
252
265
|
sessionId,
|
|
253
266
|
request,
|
|
254
267
|
signal,
|
|
268
|
+
waitMs: 0, // Like Cindy ask/plan, only the caller's lifecycle ends this wait.
|
|
255
269
|
answerOf: {
|
|
256
|
-
cancelled,
|
|
257
270
|
from: (decision) => questionAnswerOf(items, decision),
|
|
258
271
|
},
|
|
259
272
|
});
|
|
@@ -318,10 +331,11 @@ export function createApprovalRegistry({
|
|
|
318
331
|
return { accepted: true };
|
|
319
332
|
}
|
|
320
333
|
|
|
321
|
-
/**
|
|
322
|
-
function clear() {
|
|
323
|
-
for (const
|
|
324
|
-
|
|
334
|
+
/** A relay retry preserves questions; explicit shutdown cancels all waits. */
|
|
335
|
+
function clear({ preserveQuestions = false } = {}) {
|
|
336
|
+
for (const entry of [...pending.values()]) {
|
|
337
|
+
if (preserveQuestions && entry.request.kind === QUESTION_KIND) continue;
|
|
338
|
+
entry.cancel();
|
|
325
339
|
}
|
|
326
340
|
}
|
|
327
341
|
|
package/src/host-version.js
CHANGED
package/src/host.js
CHANGED
|
@@ -36,6 +36,7 @@ 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 { turnErrorRow, turnErrorPayload } from './turn-error.js';
|
|
39
40
|
import { acceptLink } from './host-authorization.js';
|
|
40
41
|
import { AuthorizationPolicy } from './authorization-policy.js';
|
|
41
42
|
import { HostStatus } from './host-status.js';
|
|
@@ -338,14 +339,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
338
339
|
/** Questions DSH is waiting on, answered by whichever controller is watching. */
|
|
339
340
|
const approvals = createApprovalRegistry({
|
|
340
341
|
now,
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// surface this Host serves: the phone's card has no countdown, the user may be
|
|
344
|
-
// away from it, and the path this replaces (the Web bundle's remote forwarder)
|
|
345
|
-
// waits indefinitely — so a short Host timeout is strictly worse than the
|
|
346
|
-
// reference. Half an hour with cancellation as the escape hatch
|
|
347
|
-
// (`maker:input:stop` settles the card immediately) mirrors the reference while
|
|
348
|
-
// still bounding a forgotten turn.
|
|
342
|
+
// Permission cards keep their existing deadline. Questions have no Host
|
|
343
|
+
// timeout: only an answer or the caller's stop/cancel lifecycle ends them.
|
|
349
344
|
timeoutMs: 30 * 60_000,
|
|
350
345
|
// Push the dismissal to every watcher, not just the controller that
|
|
351
346
|
// answered: the card is gone for all of them.
|
|
@@ -703,7 +698,11 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
703
698
|
// ones: a running badge on the session list depends on them, so they must
|
|
704
699
|
// be answerable from diagnostics too.
|
|
705
700
|
const record = (channel, watchers, payload) => recordPush(null, channel, watchers, payload);
|
|
706
|
-
|
|
701
|
+
// A summary-level error is only an invalidation hint; it must not
|
|
702
|
+
// terminate a newer turn on list-only phones either.
|
|
703
|
+
const nativeRecovery = typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function';
|
|
704
|
+
const lifecycle = item.phase === 'error' && nativeRecovery ? { ...item, phase: 'active' } : item;
|
|
705
|
+
if (ws !== null) publishLifecycle(ws, subscribers, lifecycle, record);
|
|
707
706
|
publishTurnEvent(item);
|
|
708
707
|
}));
|
|
709
708
|
await projection.start(source);
|
|
@@ -721,6 +720,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
721
720
|
* @param item - one lifecycle item from the projection sink.
|
|
722
721
|
*/
|
|
723
722
|
function publishTurnEvent(item) {
|
|
723
|
+
// The summary error signal has no durable identity and can arrive late.
|
|
724
|
+
// Recover from native boundaries instead of inventing a successful done.
|
|
725
|
+
if (item?.phase === 'error') {
|
|
726
|
+
if (typeof options.resolveCapabilities?.()?.readMessages?.turnState !== 'function') {
|
|
727
|
+
announceTurnIdle(item.sessionId); // Legacy source: keep its status-only correction.
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
for (const deviceId of sessionSubscribers.get(item.sessionId) ?? []) {
|
|
731
|
+
void restoreWatchedTurnState(deviceId, item.sessionId);
|
|
732
|
+
}
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
724
735
|
// Keep the cached row's turn state in step with the lifecycle itself.
|
|
725
736
|
//
|
|
726
737
|
// The cache is refreshed by a **list read**, and a controller sitting inside
|
|
@@ -749,6 +760,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
749
760
|
async function setSource(next) {
|
|
750
761
|
if (source !== next) {
|
|
751
762
|
sourceRevision++;
|
|
763
|
+
nativeBoundaries.clear();
|
|
764
|
+
turnVersions.clear();
|
|
765
|
+
terminalIdentities.clear();
|
|
752
766
|
source?.manualCompact?.cancelAll('DSH source replaced');
|
|
753
767
|
source?.forkSession?.cancelAll();
|
|
754
768
|
source?.keywordSearch?.cancelAll();
|
|
@@ -872,6 +886,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
872
886
|
* terminal announcement for the same turn; this is the de-duplication window.
|
|
873
887
|
*/
|
|
874
888
|
const lastTerminalAnnounce = new Map();
|
|
889
|
+
// Disposable delivery guards, not a second error ledger. Only the latest
|
|
890
|
+
// identity per device/session and native boundary per session are retained.
|
|
891
|
+
const terminalIdentities = new Map();
|
|
892
|
+
const subscriptionLeases = new Map();
|
|
893
|
+
const nativeBoundaries = new Map();
|
|
894
|
+
const turnVersions = new Map();
|
|
895
|
+
|
|
896
|
+
function renewTurnLease(deviceId, sessionId) {
|
|
897
|
+
const key = terminalAnnounceKey(deviceId, sessionId);
|
|
898
|
+
subscriptionLeases.set(key, (subscriptionLeases.get(key) ?? 0) + 1);
|
|
899
|
+
terminalIdentities.delete(key);
|
|
900
|
+
}
|
|
875
901
|
const TERMINAL_ANNOUNCE_WINDOW_MS = 30_000;
|
|
876
902
|
/**
|
|
877
903
|
* Devices the relay has told us it cannot route to.
|
|
@@ -898,6 +924,79 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
898
924
|
return `${deviceId}\u0000${sessionId}`;
|
|
899
925
|
}
|
|
900
926
|
|
|
927
|
+
/** Send a native terminal identity once per authorized subscription/connection. */
|
|
928
|
+
function sendNativeTerminal(deviceId, sessionId, payload, identity) {
|
|
929
|
+
if (stopped || !active || !acceptedControllers.has(deviceId) || !policy.canAccept(deviceId) || sessionIsHidden(sessionId)
|
|
930
|
+
|| !sessionSubscribers.get(sessionId)?.has(deviceId)) return false;
|
|
931
|
+
const key = terminalAnnounceKey(deviceId, sessionId);
|
|
932
|
+
const stamp = `${generation}:${subscriptionLeases.get(key) ?? 0}:${identity}`;
|
|
933
|
+
if (terminalIdentities.get(key) === stamp) return false;
|
|
934
|
+
recordPush(sessionId, 'maker:event', offlineDevices.has(deviceId) ? 0 : 1, payload);
|
|
935
|
+
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
|
|
936
|
+
if (!offlineDevices.has(deviceId)) {
|
|
937
|
+
terminalIdentities.set(key, stamp);
|
|
938
|
+
noteTerminalAnnounce(deviceId, sessionId);
|
|
939
|
+
}
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Event callers intentionally fire-and-forget; provider replacement must not
|
|
944
|
+
* turn a failed recovery into an unhandled rejection at the DSH boundary. */
|
|
945
|
+
async function restoreWatchedTurnState(deviceId, sessionId) {
|
|
946
|
+
try { await readWatchedTurnState(deviceId, sessionId); } catch {
|
|
947
|
+
if (!stopped) recordHandlerError('turn-recovery', new Error('Native turn recovery capability is unavailable'));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Cold/reconnect repair uses fresh native evidence; every await is generation guarded. */
|
|
952
|
+
async function readWatchedTurnState(deviceId, sessionId) {
|
|
953
|
+
const reader = options.resolveCapabilities?.()?.readMessages;
|
|
954
|
+
if (typeof reader?.turnState !== 'function') {
|
|
955
|
+
announceWatchedTurnState(deviceId, sessionId);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const owner = source; const revision = sourceRevision; const connection = generation;
|
|
959
|
+
const version = turnVersions.get(sessionId);
|
|
960
|
+
const key = terminalAnnounceKey(deviceId, sessionId);
|
|
961
|
+
const lease = subscriptionLeases.get(key);
|
|
962
|
+
const valid = () => !stopped && active && source === owner && sourceRevision === revision
|
|
963
|
+
&& generation === connection && turnVersions.get(sessionId) === version
|
|
964
|
+
&& subscriptionLeases.get(key) === lease && acceptedControllers.has(deviceId) && policy.canAccept(deviceId)
|
|
965
|
+
&& !sessionIsHidden(sessionId) && sessionSubscribers.get(sessionId)?.has(deviceId)
|
|
966
|
+
&& options.resolveCapabilities?.()?.readMessages === reader;
|
|
967
|
+
if (!valid()) return;
|
|
968
|
+
try {
|
|
969
|
+
const state = await reader.turnState(sessionId);
|
|
970
|
+
if (!valid() || !state) return;
|
|
971
|
+
// The log can end before a newly admitted turn has appended its start.
|
|
972
|
+
// A live registry check after replay prevents that gap being called idle.
|
|
973
|
+
let live;
|
|
974
|
+
if (typeof owner?.listSessionStates === 'function') {
|
|
975
|
+
const rows = await owner.listSessionStates(sessionId);
|
|
976
|
+
live = Array.isArray(rows) ? rows.find(row => String(row?.id) === sessionId) : null;
|
|
977
|
+
} else if (typeof owner?.getSession === 'function') live = await owner.getSession(sessionId);
|
|
978
|
+
if (!valid() || !live || typeof live.running !== 'boolean') return;
|
|
979
|
+
if (live.running) {
|
|
980
|
+
setCachedRunning(sessionId, true);
|
|
981
|
+
const payload = { sessionId, event: runningStatusEvent() };
|
|
982
|
+
recordPush(sessionId, 'maker:event', 1, payload);
|
|
983
|
+
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (state.running) return; // An unfinished historical turn is not live-registry evidence.
|
|
987
|
+
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) > state.boundarySeq) return;
|
|
988
|
+
// No await after the final guard: a new boundary cannot interleave with send.
|
|
989
|
+
setCachedRunning(sessionId, false);
|
|
990
|
+
const payload = state.error ? turnErrorPayload(state.error) : { sessionId, event: { type: 'done' } };
|
|
991
|
+
sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`);
|
|
992
|
+
} catch {
|
|
993
|
+
if (!valid()) return;
|
|
994
|
+
// Never reflect arbitrary upstream exception prose into remote diagnostics.
|
|
995
|
+
recordHandlerError('turn-recovery', new Error('Native turn state is unavailable; retry on the next synchronization'));
|
|
996
|
+
if (valid()) pushHistoryViewChanged(sessionId);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
901
1000
|
/** Note that one device has just been told a session's turn is over. */
|
|
902
1001
|
function noteTerminalAnnounce(deviceId, sessionId) {
|
|
903
1002
|
lastTerminalAnnounce.set(terminalAnnounceKey(deviceId, sessionId), now().getTime());
|
|
@@ -942,6 +1041,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
942
1041
|
function markDeviceUnreachable(deviceId) {
|
|
943
1042
|
if (typeof deviceId !== 'string' || deviceId === '') return;
|
|
944
1043
|
offlineDevices.add(deviceId);
|
|
1044
|
+
for (const sessionId of watchedSessionsByDevice.get(deviceId) ?? []) renewTurnLease(deviceId, sessionId);
|
|
945
1045
|
}
|
|
946
1046
|
|
|
947
1047
|
/**
|
|
@@ -964,6 +1064,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
964
1064
|
for (const sessionId of sessions) {
|
|
965
1065
|
announceWatchedTurnState(deviceId, sessionId);
|
|
966
1066
|
pushHistoryViewChanged(sessionId);
|
|
1067
|
+
replayUserQuestions(sessionId);
|
|
967
1068
|
}
|
|
968
1069
|
return sessions;
|
|
969
1070
|
}
|
|
@@ -980,6 +1081,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
980
1081
|
* @param sessionId - the session whose turn state to report.
|
|
981
1082
|
*/
|
|
982
1083
|
function announceWatchedTurnState(deviceId, sessionId) {
|
|
1084
|
+
if (typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function') {
|
|
1085
|
+
void restoreWatchedTurnState(deviceId, sessionId);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
983
1088
|
const cached = sessionRowFor(sessionId);
|
|
984
1089
|
if (cached === undefined || cached.running === true) return;
|
|
985
1090
|
const key = terminalAnnounceKey(deviceId, sessionId);
|
|
@@ -1096,6 +1201,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1096
1201
|
watchedListDevices.add(deviceId);
|
|
1097
1202
|
} else if (topic.startsWith('session:')) {
|
|
1098
1203
|
const sessionId = topic.slice('session:'.length);
|
|
1204
|
+
renewTurnLease(deviceId, sessionId);
|
|
1099
1205
|
const devices = sessionSubscribers.get(sessionId) ?? new Set();
|
|
1100
1206
|
devices.add(deviceId);
|
|
1101
1207
|
sessionSubscribers.set(sessionId, devices);
|
|
@@ -1107,6 +1213,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1107
1213
|
// …and beyond a *disconnect*: when this device re-links, the Host has to be able to
|
|
1108
1214
|
// tell it the turn state of every session it was inside, not just the running ones.
|
|
1109
1215
|
rememberWatchedSession(deviceId, sessionId);
|
|
1216
|
+
replayUserQuestions(sessionId);
|
|
1110
1217
|
}
|
|
1111
1218
|
}
|
|
1112
1219
|
}
|
|
@@ -1116,6 +1223,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1116
1223
|
if (topic === 'sessions') subscribers.delete(deviceId);
|
|
1117
1224
|
else if (topic.startsWith('session:')) {
|
|
1118
1225
|
const sessionId = topic.slice('session:'.length);
|
|
1226
|
+
renewTurnLease(deviceId, sessionId);
|
|
1119
1227
|
const devices = sessionSubscribers.get(sessionId);
|
|
1120
1228
|
if (devices === undefined) continue;
|
|
1121
1229
|
devices.delete(deviceId);
|
|
@@ -1214,25 +1322,27 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1214
1322
|
}
|
|
1215
1323
|
|
|
1216
1324
|
/**
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* stops it. The answerer therefore only claims questions for a session a
|
|
1222
|
-
* controller is actually watching, and passes every other one down the chain
|
|
1223
|
-
* so the local UI can answer it.
|
|
1224
|
-
*
|
|
1225
|
-
* Returns `null` when nobody is watching, which is the answerer's cue to call
|
|
1226
|
-
* `next()`.
|
|
1325
|
+
* Register before presentation, even when no phone has opened this session.
|
|
1326
|
+
* Watching controls push delivery only; late phones query the same pending
|
|
1327
|
+
* request while the plugin also forwards it to DSH's native answerer.
|
|
1328
|
+
* Returns null when the bridge is disabled or the session is unidentified.
|
|
1227
1329
|
*/
|
|
1228
1330
|
async function beginUserQuestion({ sessionId, questions, signal }) {
|
|
1229
1331
|
if (typeof sessionId !== 'string' || sessionId === '') return null;
|
|
1230
|
-
if (!
|
|
1332
|
+
if (!active || stopped) return null;
|
|
1231
1333
|
const handle = await approvals.askUser({ sessionId, questions, signal });
|
|
1232
|
-
pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
|
|
1334
|
+
if (!signal?.aborted) pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
|
|
1233
1335
|
return handle;
|
|
1234
1336
|
}
|
|
1235
1337
|
|
|
1338
|
+
function replayUserQuestions(sessionId) {
|
|
1339
|
+
for (const { request } of approvals.list(sessionId)) {
|
|
1340
|
+
if (request.kind === 'ask_user_question') {
|
|
1341
|
+
pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1236
1346
|
async function askUserQuestion(options) {
|
|
1237
1347
|
const handle = await beginUserQuestion(options);
|
|
1238
1348
|
return handle === null ? null : handle.answered;
|
|
@@ -1253,7 +1363,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1253
1363
|
*/
|
|
1254
1364
|
function pushSessionUpdate(sessionId, channel, payload) {
|
|
1255
1365
|
if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
|
|
1256
|
-
const
|
|
1366
|
+
const guarded = payload?.message?.role === 'error' || payload?.event?.type === 'error';
|
|
1367
|
+
const devices = guarded
|
|
1368
|
+
? new Set([...(sessionSubscribers.get(sessionId) ?? [])].filter(id => !sessionIsHidden(sessionId) && acceptedControllers.has(id) && policy.canAccept(id)))
|
|
1369
|
+
: sessionSubscribers.get(sessionId);
|
|
1257
1370
|
const watchers = devices === undefined ? 0 : devices.size;
|
|
1258
1371
|
const reachable = devices === undefined ? 0 : [...devices].filter((deviceId) => !offlineDevices.has(deviceId)).length;
|
|
1259
1372
|
recordPush(sessionId, channel, reachable, payload);
|
|
@@ -1337,7 +1450,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1337
1450
|
// nothing, which is the same class of failure as the bug being fixed.)
|
|
1338
1451
|
const provider = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : undefined;
|
|
1339
1452
|
const hydrate = provider?.hydrateImages;
|
|
1340
|
-
if (typeof hydrate === 'function') await hydrate([message]);
|
|
1453
|
+
if (message?.role !== 'error' && typeof hydrate === 'function') await hydrate([message]);
|
|
1341
1454
|
} catch {
|
|
1342
1455
|
// Un-hydrated is still worth sending.
|
|
1343
1456
|
}
|
|
@@ -1567,6 +1680,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1567
1680
|
* running turn must be told so rather than left to infer it from silence.
|
|
1568
1681
|
*/
|
|
1569
1682
|
pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
|
|
1683
|
+
restoreTurnState: typeof provided.readMessages?.turnState === 'function' ? restoreWatchedTurnState : undefined,
|
|
1570
1684
|
// The controller's goal panel refreshes from this push, so a write made on
|
|
1571
1685
|
// the phone reaches any other screen watching the same session.
|
|
1572
1686
|
pushGoalStatus: (sessionId, status) => pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: status }),
|
|
@@ -1697,7 +1811,23 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1697
1811
|
* pure noise.
|
|
1698
1812
|
* @param sessionId - the session whose turn ended.
|
|
1699
1813
|
*/
|
|
1700
|
-
function announceTurnIdle(sessionId) {
|
|
1814
|
+
function announceTurnIdle(sessionId, event) {
|
|
1815
|
+
if (!event && typeof options.resolveCapabilities?.()?.readMessages?.turnState === 'function') {
|
|
1816
|
+
for (const deviceId of sessionSubscribers.get(sessionId) ?? []) void restoreWatchedTurnState(deviceId, sessionId);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const error = turnErrorRow(event, sessionId);
|
|
1820
|
+
const failed = event?.data?.reason?.kind === 'error';
|
|
1821
|
+
if (failed && !error) {
|
|
1822
|
+
recordHandlerError('turn-error', new Error('Invalid native failed-turn identity; history resynchronization required'));
|
|
1823
|
+
pushHistoryViewChanged(sessionId);
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
if (event && Number.isSafeInteger(event.seq)) {
|
|
1827
|
+
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
|
|
1828
|
+
nativeBoundaries.set(sessionId, { seq: event.seq });
|
|
1829
|
+
}
|
|
1830
|
+
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
1701
1831
|
const timer = pendingReconciles.get(sessionId);
|
|
1702
1832
|
if (timer !== undefined) {
|
|
1703
1833
|
pendingReconciles.delete(sessionId);
|
|
@@ -1709,7 +1839,16 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1709
1839
|
// tells it first — and the re-link repair depends on that record meaning "it arrived", so a
|
|
1710
1840
|
// device the relay calls unreachable must not be written into it.
|
|
1711
1841
|
const watchers = [...(sessionSubscribers.get(sessionId) ?? [])];
|
|
1712
|
-
|
|
1842
|
+
if (failed) {
|
|
1843
|
+
const payload = turnErrorPayload(error);
|
|
1844
|
+
for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, payload, `end:${event.seq}`);
|
|
1845
|
+
// Error detail never goes to list subscribers or unwatched controllers.
|
|
1846
|
+
publishRowTurnState(sessionId, false);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (event && Number.isSafeInteger(event.seq)) {
|
|
1850
|
+
for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, { sessionId, event: { type: 'done' } }, `end:${event.seq}`);
|
|
1851
|
+
} else pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: { type: 'done' } });
|
|
1713
1852
|
for (const deviceId of watchers) {
|
|
1714
1853
|
if (!offlineDevices.has(deviceId)) noteTerminalAnnounce(deviceId, sessionId);
|
|
1715
1854
|
}
|
|
@@ -1858,7 +1997,12 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1858
1997
|
* Announce that a turn is live.
|
|
1859
1998
|
* @param sessionId - the session whose turn started.
|
|
1860
1999
|
*/
|
|
1861
|
-
function announceTurnRunning(sessionId) {
|
|
2000
|
+
function announceTurnRunning(sessionId, event) {
|
|
2001
|
+
if (event && Number.isSafeInteger(event.seq)) {
|
|
2002
|
+
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
|
|
2003
|
+
nativeBoundaries.set(sessionId, { seq: event.seq });
|
|
2004
|
+
}
|
|
2005
|
+
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
1862
2006
|
setCachedRunning(sessionId, true);
|
|
1863
2007
|
pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent() });
|
|
1864
2008
|
publishRowTurnState(sessionId, true);
|
|
@@ -2245,6 +2389,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2245
2389
|
case 'link-open': {
|
|
2246
2390
|
if (typeof frame.id !== 'string' || typeof frame.src !== 'string') return;
|
|
2247
2391
|
if (!policy.canAccept(frame.src)) return;
|
|
2392
|
+
for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
|
|
2248
2393
|
// A link-open is itself proof the relay has a route to this device.
|
|
2249
2394
|
markDeviceReachable(frame.src);
|
|
2250
2395
|
linkController(frame.src);
|
|
@@ -2271,6 +2416,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2271
2416
|
}
|
|
2272
2417
|
case 'link-close': {
|
|
2273
2418
|
if (typeof frame.src !== 'string') return;
|
|
2419
|
+
for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) renewTurnLease(frame.src, sessionId);
|
|
2274
2420
|
filePeer.closePeer(frame.src);
|
|
2275
2421
|
acceptedControllers.delete(frame.src);
|
|
2276
2422
|
observeRemindersSource();
|
|
@@ -2497,11 +2643,15 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2497
2643
|
subscribers.clear();
|
|
2498
2644
|
sessionSubscribers.clear();
|
|
2499
2645
|
liveTurnState.clear();
|
|
2646
|
+
nativeBoundaries.clear();
|
|
2647
|
+
turnVersions.clear();
|
|
2648
|
+
terminalIdentities.clear();
|
|
2649
|
+
subscriptionLeases.clear();
|
|
2500
2650
|
lastRepairPulse.clear();
|
|
2501
2651
|
directoryAsked.clear();
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
approvals.clear();
|
|
2652
|
+
// Relay recovery is not task cancellation. Keep questions for the next
|
|
2653
|
+
// subscription; explicit switch-off/stop still releases all waits.
|
|
2654
|
+
approvals.clear({ preserveQuestions: keepSwitch });
|
|
2505
2655
|
inputQueue.clear();
|
|
2506
2656
|
projections.clear();
|
|
2507
2657
|
|
|
@@ -2703,8 +2853,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2703
2853
|
* projection's running flag flaps inside a single turn, so it is not allowed
|
|
2704
2854
|
* to end one (see `turnEventFor`).
|
|
2705
2855
|
*/
|
|
2706
|
-
pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
|
|
2707
|
-
pushTurnIdle: (sessionId) => announceTurnIdle(sessionId),
|
|
2856
|
+
pushTurnRunning: (sessionId, event) => announceTurnRunning(sessionId, event),
|
|
2857
|
+
pushTurnIdle: (sessionId, event) => announceTurnIdle(sessionId, event),
|
|
2708
2858
|
/**
|
|
2709
2859
|
* Make sure a controller is not left spinning after a turn it was watching.
|
|
2710
2860
|
*
|
package/src/session-publisher.js
CHANGED
|
@@ -107,9 +107,10 @@ export function turnEventFor(item) {
|
|
|
107
107
|
return { channel: 'maker:event', payload: { sessionId, event: runningStatusEvent() } };
|
|
108
108
|
case 'waiting':
|
|
109
109
|
return { channel: 'maker:event', payload: { sessionId, event: runningStatusEvent('waiting on input') } };
|
|
110
|
-
//
|
|
110
|
+
// Summary errors have no turn identity. The native boundary/recovery path
|
|
111
|
+
// publishes an error with persistId; a bare done would erase that distinction.
|
|
111
112
|
case 'error':
|
|
112
|
-
return
|
|
113
|
+
return null;
|
|
113
114
|
// `idle` is deliberately NOT an ending here.
|
|
114
115
|
//
|
|
115
116
|
// This flag flaps: `api-session/status` reports `running: false` for a moment
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Display-only projection of a durable failed turn. DSH owns failure and retry policy. */
|
|
2
|
+
const SAFE_MESSAGES = new Map([
|
|
3
|
+
['AUTH', 'Model authentication failed. Check the provider configuration.'],
|
|
4
|
+
['MISSING_CREDENTIAL', 'Model credentials are missing. Check the provider configuration.'],
|
|
5
|
+
['INVALID_CREDENTIAL', 'Model credentials are invalid. Check the provider configuration.'],
|
|
6
|
+
['RATE_LIMIT', 'The model request was rate limited.'],
|
|
7
|
+
['QUOTA', 'The model account quota is exhausted.'],
|
|
8
|
+
['ACCOUNT_QUOTA', 'The model account quota is exhausted.'],
|
|
9
|
+
['CONTEXT_WINDOW_EXCEEDED', 'The model context window was exceeded.'],
|
|
10
|
+
['TIMEOUT', 'The model request timed out.'],
|
|
11
|
+
['TRANSPORT', 'The model connection failed.'],
|
|
12
|
+
['SERVER', 'The model service failed.'],
|
|
13
|
+
['EMPTY_RESPONSE', 'The model returned an empty response.'],
|
|
14
|
+
['NO_ADAPTER', 'The selected model route is unavailable.'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Never copy provider prose, unknown codes, request IDs or nested diagnostic objects. */
|
|
18
|
+
export function turnErrorRow(event, sessionId) {
|
|
19
|
+
if (event?.type !== 'turn/end' || event.data?.reason?.kind !== 'error') return null;
|
|
20
|
+
if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(event.seq) || event.seq < 0) return null;
|
|
21
|
+
// No wall-clock fallback: cold replay and live delivery must produce identical rows.
|
|
22
|
+
if (!Number.isFinite(event.time) || !Number.isFinite(new Date(event.time).getTime())) return null;
|
|
23
|
+
const failure = event.data.reason.error;
|
|
24
|
+
const code = SAFE_MESSAGES.has(failure?.code) ? failure.code : null;
|
|
25
|
+
const status = Number.isInteger(failure?.status) && failure.status >= 100 && failure.status <= 599 ? failure.status : null;
|
|
26
|
+
const detail = [code, status === null ? null : `HTTP ${status}`].filter(Boolean).join('; ');
|
|
27
|
+
const message = (SAFE_MESSAGES.get(code) ?? 'This turn failed. See the local DSH diagnostics for details.')
|
|
28
|
+
+ (detail ? ` (${detail})` : '');
|
|
29
|
+
const id = `${sessionId}:turn-error:${event.seq}`;
|
|
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 }) } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The maker envelope and the history row share one durable identity. */
|
|
36
|
+
export function turnErrorPayload(row) {
|
|
37
|
+
return { sessionId: row.sessionId, persistId: row.clientId, event: { type: 'error',
|
|
38
|
+
data: { ...row.content, isTerminal: true, willRetry: false } } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Latest native turn boundary, not the latest error in an arbitrary history page. */
|
|
42
|
+
export function turnStateFromEvents(events, sessionId) {
|
|
43
|
+
if (!Array.isArray(events)) throw new TypeError('Native turn recovery requires a session event snapshot');
|
|
44
|
+
let boundary = null;
|
|
45
|
+
let seq = -1;
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || event.seq <= seq
|
|
48
|
+
|| (seq >= 0 && event.seq !== seq + 1)) {
|
|
49
|
+
throw new TypeError('Native turn recovery requires ordered unique event sequences');
|
|
50
|
+
}
|
|
51
|
+
seq = event.seq;
|
|
52
|
+
if (event.type === 'turn/start' || event.type === 'turn/end') boundary = event;
|
|
53
|
+
}
|
|
54
|
+
if (!boundary) return null;
|
|
55
|
+
if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null };
|
|
56
|
+
const error = turnErrorRow(boundary, sessionId);
|
|
57
|
+
// A malformed failure is not evidence of a successful ending.
|
|
58
|
+
if (boundary.data?.reason?.kind === 'error' && !error) throw new TypeError('Invalid native failed-turn identity');
|
|
59
|
+
return { seq, boundarySeq: boundary.seq, running: false, error };
|
|
60
|
+
}
|