@adhdev/daemon-core 0.9.82-rc.378 → 0.9.82-rc.379
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/dist/cli-adapters/provider-cli-adapter.d.ts +5 -0
- package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
- package/dist/index.js +90 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +90 -28
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/fsm-driver.d.ts +6 -4
- package/package.json +2 -2
- package/src/cli-adapters/provider-cli-adapter.ts +62 -1
- package/src/cli-adapters/pty-write-chunking.ts +106 -0
- package/src/commands/med-family/mesh-crud.ts +28 -1
- package/src/commands/router.ts +21 -2
- package/src/mesh/mesh-queue-assignment.ts +34 -0
- package/src/providers/spec/fsm-driver.ts +13 -26
|
@@ -2,6 +2,7 @@ import { type SpecPtyEvent } from './adapter.js';
|
|
|
2
2
|
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
3
3
|
import { type TraceEntry } from './evaluator.js';
|
|
4
4
|
import { type TransitionEval } from './fsm-evaluator.js';
|
|
5
|
+
import { chunkPreservingSurrogates as chunkPreservingSurrogatesShared } from '../../cli-adapters/pty-write-chunking.js';
|
|
5
6
|
export type DashboardEvent = {
|
|
6
7
|
kind: 'pty_data';
|
|
7
8
|
chunk: string;
|
|
@@ -155,10 +156,11 @@ export interface SpecDriverOpts {
|
|
|
155
156
|
extraCliArgs?: string[];
|
|
156
157
|
}
|
|
157
158
|
export declare function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number;
|
|
158
|
-
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
|
|
159
|
+
/** Re-export of the shared surrogate-safe splitter so existing imports of
|
|
160
|
+
* `chunkPreservingSurrogates` from this module keep working. The implementation
|
|
161
|
+
* lives in ../../cli-adapters/pty-write-chunking so the spec driver and the
|
|
162
|
+
* legacy adapter share one definition. */
|
|
163
|
+
export declare const chunkPreservingSurrogates: typeof chunkPreservingSurrogatesShared;
|
|
162
164
|
export declare function guessExt(mime: string): string;
|
|
163
165
|
type HistoryEntry = DriverHistoryEntry;
|
|
164
166
|
export declare class FsmDriver implements ISpecDriver {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.379",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.379",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
type PtyRuntimeTransport,
|
|
28
28
|
type PtyTransportFactory,
|
|
29
29
|
} from './pty-transport.js';
|
|
30
|
+
import {
|
|
31
|
+
WIN32_PTY_WRITE_CHUNK_CHARS,
|
|
32
|
+
WIN32_PTY_WRITE_CHUNK_GAP_MS,
|
|
33
|
+
chunkPreservingSurrogates,
|
|
34
|
+
shouldChunkWin32Write,
|
|
35
|
+
} from './pty-write-chunking.js';
|
|
30
36
|
import {
|
|
31
37
|
buildCliScreenSnapshot,
|
|
32
38
|
compactPromptText,
|
|
@@ -744,6 +750,11 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
744
750
|
`[${this.cliType}] Startup settled (${trigger}, stableMs=${stableMs}, modal=${!!startupModal}) providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'} scriptsPath=${this.providerResolutionMeta.scriptsPath || '-'}`
|
|
745
751
|
);
|
|
746
752
|
this.onStatusChange?.();
|
|
753
|
+
// Readiness barrier flush: a message queued because the session was not yet
|
|
754
|
+
// ready (sendMessageNow's not_ready_pending_prompt path) has no turn-completion
|
|
755
|
+
// event to trigger its flush. Now that the interactive prompt is up and we are
|
|
756
|
+
// idle, drain it. No-op when the queue is empty or we settled to a modal.
|
|
757
|
+
if (!startupModal) this.schedulePendingOutboundFlush();
|
|
747
758
|
}
|
|
748
759
|
|
|
749
760
|
private scheduleStartupSettleCheck(): void {
|
|
@@ -1153,9 +1164,42 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1153
1164
|
|
|
1154
1165
|
private async writeToPty(data: string): Promise<void> {
|
|
1155
1166
|
if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
|
|
1167
|
+
// win32 ConPTY paced write: a single unbounded write beyond ~1KB overflows
|
|
1168
|
+
// the console input pipe and drops LEADING bytes (the "long message gets
|
|
1169
|
+
// truncated, head lost / tail kept" failure). Split a large payload into
|
|
1170
|
+
// bounded, surrogate-safe chunks written with a short gap so the console
|
|
1171
|
+
// reader keeps up. Small payloads (the common case — short prompts, lone
|
|
1172
|
+
// submit keys) still go out in a single write.
|
|
1173
|
+
//
|
|
1174
|
+
// The submit key, when present, is the TAIL of `data` (callers pass
|
|
1175
|
+
// `body + sendKey` for the atomic-submit paths). Because we chunk the
|
|
1176
|
+
// combined string, the submit key always rides in the SAME final write as
|
|
1177
|
+
// the body's tail — the win32 invariant that ConPTY recognizes it as a
|
|
1178
|
+
// submit — and is never emitted before the whole body has been written
|
|
1179
|
+
// (no partial-body submit). Body-only writes (wait_for_echo strategy) have
|
|
1180
|
+
// their submit key sent separately by the caller afterwards, unchanged.
|
|
1181
|
+
if (process.platform === 'win32' && shouldChunkWin32Write(data.length)) {
|
|
1182
|
+
await this.writeWin32Chunked(data);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1156
1185
|
await this.ptyProcess.write(data);
|
|
1157
1186
|
}
|
|
1158
1187
|
|
|
1188
|
+
/** Write `data` to the PTY in bounded, surrogate-safe chunks with a short
|
|
1189
|
+
* inter-chunk gap (win32 paced write). Awaits each chunk's write and the gap
|
|
1190
|
+
* so the returned promise resolves only after the FINAL chunk (carrying any
|
|
1191
|
+
* trailing submit key) has been written. */
|
|
1192
|
+
private async writeWin32Chunked(data: string): Promise<void> {
|
|
1193
|
+
const chunks = chunkPreservingSurrogates(data, WIN32_PTY_WRITE_CHUNK_CHARS);
|
|
1194
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
1195
|
+
if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
|
|
1196
|
+
await this.ptyProcess.write(chunks[i]);
|
|
1197
|
+
if (i + 1 < chunks.length) {
|
|
1198
|
+
await new Promise<void>(resolve => setTimeout(resolve, WIN32_PTY_WRITE_CHUNK_GAP_MS));
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1159
1203
|
private resetPendingSendState(reason: string): void {
|
|
1160
1204
|
this.responseBuffer = '';
|
|
1161
1205
|
if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
|
|
@@ -1472,7 +1516,24 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1472
1516
|
LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
|
|
1473
1517
|
}
|
|
1474
1518
|
}
|
|
1475
|
-
if (!this.ready)
|
|
1519
|
+
if (!this.ready) {
|
|
1520
|
+
// Readiness barrier (queue-until-ready). A task dispatched the instant a
|
|
1521
|
+
// freshly-spawned session is launched can arrive BEFORE the PTY prints its
|
|
1522
|
+
// interactive prompt (this.ready flips ~2-6s later). Previously this threw
|
|
1523
|
+
// "not ready" and the delegated-task delivery promise requeued the task,
|
|
1524
|
+
// which on win32 raced the auto-launch cooldown and could strand the worker
|
|
1525
|
+
// idle with no work (the "first big message lost" failure). Instead, when the
|
|
1526
|
+
// caller allows queueing, BUFFER the message in the pending-outbound queue and
|
|
1527
|
+
// return — the startup-settle path flips this.ready and flushes the queue once
|
|
1528
|
+
// the prompt is actually up (see resolveStartupState → flushPendingOutboundQueue),
|
|
1529
|
+
// so the message is delivered late rather than dropped. A non-queueable caller
|
|
1530
|
+
// (e.g. an internal flush) still throws so it isn't silently swallowed.
|
|
1531
|
+
if (allowQueue) {
|
|
1532
|
+
this.enqueuePendingOutboundMessage(text, 'not_ready_pending_prompt');
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
|
|
1536
|
+
}
|
|
1476
1537
|
const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
|
|
1477
1538
|
? String(parsedStatusBeforeSend.status)
|
|
1478
1539
|
: '';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared win32 ConPTY paced-write chunking.
|
|
3
|
+
*
|
|
4
|
+
* A single unbounded ConPTY `write()` can overflow the console input pipe and
|
|
5
|
+
* drop LEADING bytes once the payload exceeds ~1KB — the "long task message gets
|
|
6
|
+
* truncated (head lost, tail kept)" failure. The fix is to split a large body
|
|
7
|
+
* into bounded chunks written with a short inter-chunk gap so the console input
|
|
8
|
+
* buffer keeps up. Small bodies still go out in a single write.
|
|
9
|
+
*
|
|
10
|
+
* This module is the SINGLE source of truth for the chunk size / gap / surrogate-
|
|
11
|
+
* safe split so the two write paths that need it — the spec FsmDriver
|
|
12
|
+
* (writeWin32Body) and the legacy ProviderCliAdapter (writeToPty / submit paths)
|
|
13
|
+
* — cannot drift apart and regress on one side (the original bug: "one branch
|
|
14
|
+
* patched, the other not").
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// Defensive paced PTY write tuning. 1024 chars per chunk stays comfortably under
|
|
19
|
+
// the ConPTY input-pipe threshold; an 8ms gap lets the console reader drain
|
|
20
|
+
// between chunks without adding meaningful latency to a normal-sized prompt.
|
|
21
|
+
export const WIN32_PTY_WRITE_CHUNK_CHARS = 1024;
|
|
22
|
+
export const WIN32_PTY_WRITE_CHUNK_GAP_MS = 8;
|
|
23
|
+
|
|
24
|
+
/** Split `text` into chunks of at most `size` UTF-16 units without ever cutting
|
|
25
|
+
* between a high and low surrogate (which would corrupt an astral char — emoji,
|
|
26
|
+
* etc. — on the UTF-8 PTY write). */
|
|
27
|
+
export function chunkPreservingSurrogates(text: string, size: number): string[] {
|
|
28
|
+
const chunks: string[] = [];
|
|
29
|
+
let offset = 0;
|
|
30
|
+
while (offset < text.length) {
|
|
31
|
+
let end = Math.min(text.length, offset + size);
|
|
32
|
+
if (end < text.length) {
|
|
33
|
+
const code = text.charCodeAt(end - 1);
|
|
34
|
+
// Boundary lands on a high surrogate → pull back one so the pair stays
|
|
35
|
+
// together in the next chunk.
|
|
36
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
37
|
+
}
|
|
38
|
+
if (end <= offset) end = Math.min(text.length, offset + size); // size 1 on a lone surrogate
|
|
39
|
+
chunks.push(text.slice(offset, end));
|
|
40
|
+
offset = end;
|
|
41
|
+
}
|
|
42
|
+
return chunks;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when a body of `length` UTF-16 units should be paced into multiple
|
|
46
|
+
* chunks on win32 rather than written in a single PTY write. */
|
|
47
|
+
export function shouldChunkWin32Write(length: number): boolean {
|
|
48
|
+
return length > WIN32_PTY_WRITE_CHUNK_CHARS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Drive a paced, surrogate-safe chunked write of `text` over a `write(chunk)`
|
|
53
|
+
* sink, calling `onChunkWritten` after each chunk (e.g. to advance an input-
|
|
54
|
+
* activity timestamp) and `onDone` once the final chunk is out. The optional
|
|
55
|
+
* `setTimer` lets the caller own the timer handle (so it can be cleared on
|
|
56
|
+
* shutdown) and supply a custom scheduler in tests; it defaults to setTimeout.
|
|
57
|
+
*
|
|
58
|
+
* Bodies at or below the chunk threshold are written in a SINGLE write — the
|
|
59
|
+
* common case — so this is a no-op pacing wrapper for normal-sized prompts.
|
|
60
|
+
*
|
|
61
|
+
* Returns the chunks that will be written (useful for assertions/logging).
|
|
62
|
+
*/
|
|
63
|
+
export interface PacedWin32WriteOptions {
|
|
64
|
+
write: (chunk: string) => void;
|
|
65
|
+
onChunkWritten?: () => void;
|
|
66
|
+
onDone?: () => void;
|
|
67
|
+
/** Schedule the next chunk; must return a handle the caller can clear.
|
|
68
|
+
* Defaults to setTimeout. */
|
|
69
|
+
setTimer?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
70
|
+
/** Store the pending timer handle so the caller can clear it on shutdown. */
|
|
71
|
+
onTimer?: (handle: ReturnType<typeof setTimeout> | null) => void;
|
|
72
|
+
chunkChars?: number;
|
|
73
|
+
gapMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeWin32Paced(text: string, opts: PacedWin32WriteOptions): string[] {
|
|
77
|
+
const chunkChars = opts.chunkChars ?? WIN32_PTY_WRITE_CHUNK_CHARS;
|
|
78
|
+
const gapMs = opts.gapMs ?? WIN32_PTY_WRITE_CHUNK_GAP_MS;
|
|
79
|
+
const setTimer = opts.setTimer ?? ((fn, delayMs) => setTimeout(fn, delayMs));
|
|
80
|
+
|
|
81
|
+
if (text.length <= chunkChars) {
|
|
82
|
+
opts.onTimer?.(null);
|
|
83
|
+
opts.write(text);
|
|
84
|
+
opts.onChunkWritten?.();
|
|
85
|
+
opts.onDone?.();
|
|
86
|
+
return [text];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const chunks = chunkPreservingSurrogates(text, chunkChars);
|
|
90
|
+
let idx = 0;
|
|
91
|
+
const writeNext = (): void => {
|
|
92
|
+
opts.onTimer?.(null);
|
|
93
|
+
if (idx >= chunks.length) { opts.onDone?.(); return; }
|
|
94
|
+
opts.write(chunks[idx]);
|
|
95
|
+
opts.onChunkWritten?.();
|
|
96
|
+
idx += 1;
|
|
97
|
+
if (idx < chunks.length) {
|
|
98
|
+
const handle = setTimer(writeNext, gapMs);
|
|
99
|
+
opts.onTimer?.(handle);
|
|
100
|
+
} else {
|
|
101
|
+
opts.onDone?.();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
writeNext();
|
|
105
|
+
return chunks;
|
|
106
|
+
}
|
|
@@ -314,8 +314,18 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// Default worktree session cleanup ON: when the caller OMITS a mode and the
|
|
318
|
+
// node is a local worktree, default to 'stop_and_delete' instead of the mesh
|
|
319
|
+
// policy ('preserve' by default). A worktree's chat session has no reason to
|
|
320
|
+
// outlive the worktree's node + directory + branch, and leaving it on
|
|
321
|
+
// 'preserve' is what orphaned chats after a worktree remove. An explicit mode
|
|
322
|
+
// (including an explicit 'preserve') is always honored — only the OMITTED case
|
|
323
|
+
// changes, and only for worktrees. Base nodes keep arg ?? policy ?? preserve.
|
|
324
|
+
const explicitCleanupMode = args?.sessionCleanupMode ?? args?.session_cleanup_mode;
|
|
317
325
|
const sessionCleanupMode = ctx.normalizeMeshSessionCleanupMode(
|
|
318
|
-
|
|
326
|
+
explicitCleanupMode
|
|
327
|
+
?? (node?.isLocalWorktree === true ? 'stop_and_delete' : undefined)
|
|
328
|
+
?? mesh?.policy?.sessionCleanupOnNodeRemove,
|
|
319
329
|
);
|
|
320
330
|
// Explicit sessionIds (e.g. supplied by refine auto-cleanup) bypass the
|
|
321
331
|
// workspace-only-match guard so a delegate session that lacks a
|
|
@@ -423,12 +433,29 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
423
433
|
const residueWarning = worktreeCleanup?.residue === true && typeof worktreeCleanup?.residueWarning === 'string'
|
|
424
434
|
? worktreeCleanup.residueWarning
|
|
425
435
|
: undefined;
|
|
436
|
+
|
|
437
|
+
// Orphan guard: if the session cleanup still left any LIVE session skipped
|
|
438
|
+
// (e.g. a future skip reason, or a workspace-only session on a base node),
|
|
439
|
+
// surface it at the top level so the caller knows a manual mesh_cleanup_sessions
|
|
440
|
+
// is still required for those sessionIds. Without this, a skipped-live session
|
|
441
|
+
// silently outlives a removed node (the very NODE-REMOVE-SESSION-ORPHAN bug).
|
|
442
|
+
const skippedLiveSessionIds = Array.isArray(sessionCleanup?.skippedLiveSessionIds)
|
|
443
|
+
? (sessionCleanup!.skippedLiveSessionIds as unknown[]).filter((v): v is string => typeof v === 'string')
|
|
444
|
+
: [];
|
|
445
|
+
const orphanedSessionsRemaining = skippedLiveSessionIds.length > 0;
|
|
446
|
+
const orphanNextAction = orphanedSessionsRemaining
|
|
447
|
+
? `Live session(s) [${skippedLiveSessionIds.join(', ')}] were skipped and still survive this node removal. `
|
|
448
|
+
+ `Run mesh_cleanup_sessions with mode:'stop_and_delete' and sessionIds:[${skippedLiveSessionIds.map(id => `'${id}'`).join(', ')}] to release them.`
|
|
449
|
+
: undefined;
|
|
426
450
|
return {
|
|
427
451
|
success: true,
|
|
428
452
|
removed,
|
|
429
453
|
...(residueWarning ? { residueWarning } : {}),
|
|
430
454
|
...(sessionCleanup ? { sessionCleanup } : {}),
|
|
431
455
|
...(worktreeCleanup ? { worktreeCleanup } : {}),
|
|
456
|
+
...(orphanedSessionsRemaining
|
|
457
|
+
? { orphanedSessionsRemaining: true, nextAction: orphanNextAction }
|
|
458
|
+
: {}),
|
|
432
459
|
};
|
|
433
460
|
} catch (e: any) {
|
|
434
461
|
return { success: false, error: e.message };
|
package/src/commands/router.ts
CHANGED
|
@@ -1221,10 +1221,24 @@ export class DaemonCommandRouter {
|
|
|
1221
1221
|
// Only the conservative shared-daemon guard for live sessions that are NOT a delegate
|
|
1222
1222
|
// explicitly bound to this node. Delegate-bound live sessions fall through and are
|
|
1223
1223
|
// stopped/deleted by the mode handlers below (which already record an intentional stop).
|
|
1224
|
-
|
|
1224
|
+
//
|
|
1225
|
+
// Worktree-removal exception: when a WORKTREE node is being removed
|
|
1226
|
+
// (source === 'mesh_remove_node' AND node.isLocalWorktree === true), its
|
|
1227
|
+
// node-binding is already gone, so a still-live session in that workspace
|
|
1228
|
+
// matches by workspace ALONE (recordNodeId is empty). The shared-daemon
|
|
1229
|
+
// concern does not apply — a worktree has a private workspace path that is
|
|
1230
|
+
// not shared with the base/other nodes — so leaving it skipped orphans the
|
|
1231
|
+
// chat after the node + worktree dir + branch are gone. Clean it instead.
|
|
1232
|
+
// This narrowly covers ONLY the pure workspace-only-no-binding case; a
|
|
1233
|
+
// session bound to ANOTHER node (recordNodeId set and != this node) is still
|
|
1234
|
+
// skipped (live_delegate_bound_to_other_node), and the coordinator session is
|
|
1235
|
+
// already protected unconditionally above.
|
|
1236
|
+
const matchedByWorkspaceOnly = !recordNodeId;
|
|
1237
|
+
const isWorktreeNodeRemoval = cleanupSource === 'mesh_remove_node' && args.node?.isLocalWorktree === true;
|
|
1238
|
+
const cleanWorkspaceOnlyForWorktree = isWorktreeNodeRemoval && matchedByWorkspaceOnly;
|
|
1239
|
+
if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode && !cleanWorkspaceOnlyForWorktree) {
|
|
1225
1240
|
skippedSessionIds.push(sessionId);
|
|
1226
1241
|
skippedLiveSessionIds.push(sessionId);
|
|
1227
|
-
const matchedByWorkspaceOnly = !recordNodeId;
|
|
1228
1242
|
const reason = recordNodeId && recordNodeId !== args.nodeId
|
|
1229
1243
|
? `live_delegate_bound_to_other_node:${recordNodeId}`
|
|
1230
1244
|
: matchedByWorkspaceOnly
|
|
@@ -1233,6 +1247,11 @@ export class DaemonCommandRouter {
|
|
|
1233
1247
|
skippedLiveSessionReasons.push({ sessionId, reason });
|
|
1234
1248
|
continue;
|
|
1235
1249
|
}
|
|
1250
|
+
if (cleanWorkspaceOnlyForWorktree && !delegateBoundToThisNode) {
|
|
1251
|
+
// A workspace-only live session on a worktree being removed is treated
|
|
1252
|
+
// like a bound delegate for accounting (so callers can see it was acted on).
|
|
1253
|
+
actedLiveDelegateSessionIds.push(sessionId);
|
|
1254
|
+
}
|
|
1236
1255
|
if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
|
|
1237
1256
|
// delete_stopped never stops live runtimes by contract — even bound delegates.
|
|
1238
1257
|
// Surface a clear reason instead of an unexplained skip so callers know to use
|
|
@@ -133,6 +133,30 @@ interface DeliverTaskContext {
|
|
|
133
133
|
sourceCoordinatorDaemonId?: string;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Readiness barrier for the LOCAL auto-launch path. A just-spawned CLI session is
|
|
137
|
+
// not interactive until its PTY prints the input prompt (the adapter flips
|
|
138
|
+
// isReady() / settles to idle ~2-6s later). Poll the local adapter until it reports
|
|
139
|
+
// ready (or idle), bounded by a generous timeout so a slow/contended boot still
|
|
140
|
+
// lands, and a hard cap so a session that never becomes interactive doesn't block the
|
|
141
|
+
// reconcile loop forever (the adapter's queue-until-ready path is the backstop then).
|
|
142
|
+
const LOCAL_LAUNCH_READY_TIMEOUT_MS = 15_000;
|
|
143
|
+
const LOCAL_LAUNCH_READY_POLL_MS = 100;
|
|
144
|
+
|
|
145
|
+
async function waitForLocalSessionReady(components: DaemonComponents, sessionId: string): Promise<void> {
|
|
146
|
+
const adapter = components.cliManager?.adapters?.get(sessionId) as
|
|
147
|
+
| { isReady?: () => boolean; currentStatus?: string }
|
|
148
|
+
| undefined;
|
|
149
|
+
// No locally-resolvable adapter (e.g. a remote/forwarded session that somehow
|
|
150
|
+
// reached this branch) → nothing to wait on; let dispatch proceed.
|
|
151
|
+
if (!adapter || typeof adapter.isReady !== 'function') return;
|
|
152
|
+
const deadline = Date.now() + LOCAL_LAUNCH_READY_TIMEOUT_MS;
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
if (adapter.isReady() || adapter.currentStatus === 'idle') return;
|
|
155
|
+
await new Promise<void>(resolve => setTimeout(resolve, LOCAL_LAUNCH_READY_POLL_MS));
|
|
156
|
+
}
|
|
157
|
+
LOG.warn('MeshQueue', `Auto-launched session ${sessionId} not interactive after ${LOCAL_LAUNCH_READY_TIMEOUT_MS}ms; dispatching anyway (adapter queue-until-ready will buffer)`);
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
// CONS scope 3: the SINGLE source of truth for dispatching a claimed task to its
|
|
137
161
|
// session. The remote (P2P dispatchMeshCommand) and local (cliManager.handleCliCommand)
|
|
138
162
|
// branches differ ONLY in the transport call — the delivery record, the delivered/failed
|
|
@@ -1130,6 +1154,16 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1130
1154
|
return false;
|
|
1131
1155
|
}
|
|
1132
1156
|
markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId });
|
|
1157
|
+
// Readiness barrier: a freshly-spawned local CLI session is NOT yet
|
|
1158
|
+
// interactive — its PTY prints the input prompt (and the adapter flips
|
|
1159
|
+
// isReady()) only ~2-6s after launch. Dispatching the task immediately
|
|
1160
|
+
// pushes the first (often large) message into a not-yet-ready PTY, which
|
|
1161
|
+
// could throw "not ready" and bounce the task through requeue (on win32
|
|
1162
|
+
// this raced the auto-launch cooldown and stranded the worker idle).
|
|
1163
|
+
// Await interactive readiness before claiming/dispatching so the very
|
|
1164
|
+
// first message lands cleanly. The adapter's queue-until-ready path is the
|
|
1165
|
+
// backstop if readiness is reported late; this just avoids the churn.
|
|
1166
|
+
await waitForLocalSessionReady(components, sessionId);
|
|
1133
1167
|
tryAssignQueueTask(components, meshId, nodeId, sessionId, resolved.providerType);
|
|
1134
1168
|
return true;
|
|
1135
1169
|
} catch (e: any) {
|
|
@@ -38,6 +38,11 @@ import { loadFsmSpec } from './fsm-loader.js';
|
|
|
38
38
|
import { applyPreLaunchTrust } from './pre-launch-trust.js';
|
|
39
39
|
import type { Control, DelegateTrigger } from './types.js';
|
|
40
40
|
import { LOG } from '../../logging/logger.js';
|
|
41
|
+
import {
|
|
42
|
+
WIN32_PTY_WRITE_CHUNK_CHARS,
|
|
43
|
+
WIN32_PTY_WRITE_CHUNK_GAP_MS,
|
|
44
|
+
chunkPreservingSurrogates as chunkPreservingSurrogatesShared,
|
|
45
|
+
} from '../../cli-adapters/pty-write-chunking.js';
|
|
41
46
|
|
|
42
47
|
// ── Shared driver types (formerly in driver.ts) ───────────────────────────
|
|
43
48
|
|
|
@@ -169,12 +174,9 @@ const WIN32_SUBMIT_MAX_RESENDS = 14;
|
|
|
169
174
|
// cadence while it waits for the body to echo.
|
|
170
175
|
const WIN32_SUBMIT_SETTLE_MS = 500;
|
|
171
176
|
const WIN32_SUBMIT_SETTLE_POLL_MS = 120;
|
|
172
|
-
// Defensive paced PTY write
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// write in one shot.
|
|
176
|
-
const WIN32_PTY_WRITE_CHUNK_CHARS = 1024;
|
|
177
|
-
const WIN32_PTY_WRITE_CHUNK_GAP_MS = 8;
|
|
177
|
+
// Defensive paced PTY write tuning (WIN32_PTY_WRITE_CHUNK_CHARS / _GAP_MS) and the
|
|
178
|
+
// surrogate-safe splitter now live in the shared pty-write-chunking module so this
|
|
179
|
+
// driver and the legacy ProviderCliAdapter cannot drift apart — see the import above.
|
|
178
180
|
// Echo-gate for the win32 FIRST submit CR (supersedes the bare output-quiet settle).
|
|
179
181
|
// The body write can race claude-cli's boot — its stdin reader is not wired until the
|
|
180
182
|
// composer renders (~5–7s in, later under load), so a too-early write is buffered and
|
|
@@ -202,26 +204,11 @@ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text:
|
|
|
202
204
|
return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
|
|
203
205
|
}
|
|
204
206
|
|
|
205
|
-
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
let offset = 0;
|
|
211
|
-
while (offset < text.length) {
|
|
212
|
-
let end = Math.min(text.length, offset + size);
|
|
213
|
-
if (end < text.length) {
|
|
214
|
-
const code = text.charCodeAt(end - 1);
|
|
215
|
-
// Boundary lands on a high surrogate → pull back one so the pair stays
|
|
216
|
-
// together in the next chunk.
|
|
217
|
-
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
218
|
-
}
|
|
219
|
-
if (end <= offset) end = Math.min(text.length, offset + size); // size 1 on a lone surrogate
|
|
220
|
-
chunks.push(text.slice(offset, end));
|
|
221
|
-
offset = end;
|
|
222
|
-
}
|
|
223
|
-
return chunks;
|
|
224
|
-
}
|
|
207
|
+
/** Re-export of the shared surrogate-safe splitter so existing imports of
|
|
208
|
+
* `chunkPreservingSurrogates` from this module keep working. The implementation
|
|
209
|
+
* lives in ../../cli-adapters/pty-write-chunking so the spec driver and the
|
|
210
|
+
* legacy adapter share one definition. */
|
|
211
|
+
export const chunkPreservingSurrogates = chunkPreservingSurrogatesShared;
|
|
225
212
|
|
|
226
213
|
export function guessExt(mime: string): string {
|
|
227
214
|
if (/png/i.test(mime)) return '.png';
|