@adhdev/daemon-core 0.9.77-rc.5 → 0.9.77-rc.50
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/boot/daemon-lifecycle.d.ts +3 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +4 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +14 -4
- package/dist/commands/mesh-coordinator.d.ts +10 -0
- package/dist/commands/router.d.ts +4 -1
- package/dist/config/mesh-config.d.ts +1 -0
- package/dist/git/git-worktree.d.ts +15 -2
- package/dist/index.d.ts +8 -4
- package/dist/index.js +1959 -291
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1947 -291
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +10 -7
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +55 -0
- package/dist/mesh/mesh-ledger.d.ts +84 -4
- package/dist/mesh/mesh-sync.d.ts +4 -12
- package/dist/mesh/mesh-work-queue.d.ts +56 -1
- package/dist/mesh/p2p-relay-failure.d.ts +35 -0
- package/dist/providers/cli-provider-instance.d.ts +6 -0
- package/dist/repo-mesh-types.d.ts +2 -0
- package/dist/shared-types.d.ts +38 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +5 -0
- package/src/cli-adapters/provider-cli-adapter.ts +35 -4
- package/src/cli-adapters/provider-cli-shared.ts +14 -4
- package/src/commands/cli-manager.ts +0 -4
- package/src/commands/mesh-coordinator.ts +55 -7
- package/src/commands/router.ts +847 -26
- package/src/commands/stream-commands.ts +8 -1
- package/src/config/config.ts +2 -1
- package/src/config/mesh-config.ts +2 -0
- package/src/config/workspaces.ts +1 -1
- package/src/git/git-worktree.ts +56 -4
- package/src/index.d.ts +3 -0
- package/src/index.ts +20 -4
- package/src/mesh/coordinator-prompt.ts +21 -10
- package/src/mesh/mesh-events.ts +522 -22
- package/src/mesh/mesh-ledger-reconciliation.ts +115 -0
- package/src/mesh/mesh-ledger.ts +209 -8
- package/src/mesh/mesh-sync.ts +4 -34
- package/src/mesh/mesh-work-queue.ts +163 -10
- package/src/mesh/p2p-relay-failure.ts +152 -0
- package/src/providers/cli-provider-instance.ts +153 -30
- package/src/repo-mesh-types.ts +2 -0
- package/src/shared-types.ts +38 -0
|
@@ -3,7 +3,12 @@ import { join } from 'path';
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { getLedgerDir } from './mesh-ledger.js';
|
|
5
5
|
|
|
6
|
-
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed';
|
|
6
|
+
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
7
|
+
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
8
|
+
export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
|
|
9
|
+
|
|
10
|
+
export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
|
|
11
|
+
export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
|
|
7
12
|
|
|
8
13
|
export interface MeshWorkQueueEntry {
|
|
9
14
|
id: string;
|
|
@@ -12,10 +17,28 @@ export interface MeshWorkQueueEntry {
|
|
|
12
17
|
status: MeshTaskStatus;
|
|
13
18
|
/** If specified, only this node can claim the task (used by legacy mesh_send_task) */
|
|
14
19
|
targetNodeId?: string;
|
|
20
|
+
/** If specified, only this runtime session can claim the task */
|
|
21
|
+
targetSessionId?: string;
|
|
15
22
|
/** The node that actually claimed and is executing the task */
|
|
16
23
|
assignedNodeId?: string;
|
|
17
24
|
/** The session currently executing the task */
|
|
18
25
|
assignedSessionId?: string;
|
|
26
|
+
/** Human/operator reason for terminal cancellation. */
|
|
27
|
+
cancelReason?: string;
|
|
28
|
+
cancelledAt?: string;
|
|
29
|
+
/** Human/operator reason for manually requeueing a task. */
|
|
30
|
+
requeueReason?: string;
|
|
31
|
+
requeuedAt?: string;
|
|
32
|
+
requeueCount?: number;
|
|
33
|
+
/** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
|
|
34
|
+
autoLaunch?: {
|
|
35
|
+
status: 'skipped' | 'started' | 'failed' | 'completed';
|
|
36
|
+
reason?: string;
|
|
37
|
+
nodeId?: string;
|
|
38
|
+
providerType?: string;
|
|
39
|
+
sessionId?: string;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
};
|
|
19
42
|
createdAt: string;
|
|
20
43
|
updatedAt: string;
|
|
21
44
|
}
|
|
@@ -47,7 +70,7 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
|
47
70
|
export function enqueueTask(
|
|
48
71
|
meshId: string,
|
|
49
72
|
message: string,
|
|
50
|
-
opts?: { targetNodeId?: string }
|
|
73
|
+
opts?: { targetNodeId?: string; targetSessionId?: string }
|
|
51
74
|
): MeshWorkQueueEntry {
|
|
52
75
|
const queue = readQueue(meshId);
|
|
53
76
|
const entry: MeshWorkQueueEntry = {
|
|
@@ -56,6 +79,7 @@ export function enqueueTask(
|
|
|
56
79
|
message,
|
|
57
80
|
status: 'pending',
|
|
58
81
|
targetNodeId: opts?.targetNodeId,
|
|
82
|
+
targetSessionId: opts?.targetSessionId,
|
|
59
83
|
createdAt: new Date().toISOString(),
|
|
60
84
|
updatedAt: new Date().toISOString(),
|
|
61
85
|
};
|
|
@@ -81,13 +105,25 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
|
|
|
81
105
|
*/
|
|
82
106
|
export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
|
|
83
107
|
const queue = readQueue(meshId);
|
|
108
|
+
|
|
109
|
+
// A worker must finish or fail its current queued assignment before it can
|
|
110
|
+
// claim another one. maxParallelTasks limits total mesh concurrency; it is
|
|
111
|
+
// not permission for one node/session to accumulate multiple assigned items.
|
|
112
|
+
const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
|
|
113
|
+
q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
|
|
114
|
+
));
|
|
115
|
+
if (hasActiveAssignment) return null;
|
|
84
116
|
|
|
85
117
|
// Find highest priority task:
|
|
86
|
-
// 1. Pending tasks explicitly targeted at this
|
|
87
|
-
// 2. Pending tasks
|
|
88
|
-
|
|
118
|
+
// 1. Pending tasks explicitly targeted at this runtime session
|
|
119
|
+
// 2. Pending tasks explicitly targeted at this node (but not another session)
|
|
120
|
+
// 3. Pending tasks with no target node/session
|
|
121
|
+
let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
|
|
89
122
|
if (targetIdx === -1) {
|
|
90
|
-
targetIdx = queue.findIndex(q => q.status === 'pending' && !q.
|
|
123
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
|
|
124
|
+
}
|
|
125
|
+
if (targetIdx === -1) {
|
|
126
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
|
|
91
127
|
}
|
|
92
128
|
|
|
93
129
|
if (targetIdx === -1) return null;
|
|
@@ -121,6 +157,83 @@ export function updateTaskStatus(
|
|
|
121
157
|
return queue[idx];
|
|
122
158
|
}
|
|
123
159
|
|
|
160
|
+
export function recordTaskAutoLaunch(
|
|
161
|
+
meshId: string,
|
|
162
|
+
taskId: string,
|
|
163
|
+
autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
|
|
164
|
+
): MeshWorkQueueEntry | null {
|
|
165
|
+
const queue = readQueue(meshId);
|
|
166
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
167
|
+
if (idx === -1) return null;
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
queue[idx].autoLaunch = {
|
|
170
|
+
...autoLaunch,
|
|
171
|
+
updatedAt: now,
|
|
172
|
+
};
|
|
173
|
+
queue[idx].updatedAt = now;
|
|
174
|
+
writeQueue(meshId, queue);
|
|
175
|
+
return queue[idx];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Mark a queue task as manually cancelled without deleting audit history.
|
|
180
|
+
*/
|
|
181
|
+
export function cancelTask(
|
|
182
|
+
meshId: string,
|
|
183
|
+
taskId: string,
|
|
184
|
+
opts?: { reason?: string },
|
|
185
|
+
): MeshWorkQueueEntry | null {
|
|
186
|
+
const queue = readQueue(meshId);
|
|
187
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
188
|
+
if (idx === -1) return null;
|
|
189
|
+
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
queue[idx].status = 'cancelled';
|
|
192
|
+
queue[idx].updatedAt = now;
|
|
193
|
+
queue[idx].cancelledAt = now;
|
|
194
|
+
if (opts?.reason) queue[idx].cancelReason = opts.reason;
|
|
195
|
+
writeQueue(meshId, queue);
|
|
196
|
+
return queue[idx];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Return a queue task to pending for retry. By default, dead session targeting
|
|
201
|
+
* and assigned ownership are cleared so stale assignments do not strand again.
|
|
202
|
+
*/
|
|
203
|
+
export function requeueTask(
|
|
204
|
+
meshId: string,
|
|
205
|
+
taskId: string,
|
|
206
|
+
opts?: {
|
|
207
|
+
reason?: string;
|
|
208
|
+
targetNodeId?: string;
|
|
209
|
+
targetSessionId?: string;
|
|
210
|
+
clearTargetNode?: boolean;
|
|
211
|
+
clearTargetSession?: boolean;
|
|
212
|
+
},
|
|
213
|
+
): MeshWorkQueueEntry | null {
|
|
214
|
+
const queue = readQueue(meshId);
|
|
215
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
216
|
+
if (idx === -1) return null;
|
|
217
|
+
|
|
218
|
+
const entry = queue[idx];
|
|
219
|
+
const now = new Date().toISOString();
|
|
220
|
+
entry.status = 'pending';
|
|
221
|
+
delete entry.assignedNodeId;
|
|
222
|
+
delete entry.assignedSessionId;
|
|
223
|
+
delete entry.cancelledAt;
|
|
224
|
+
delete entry.cancelReason;
|
|
225
|
+
if (opts?.clearTargetNode) delete entry.targetNodeId;
|
|
226
|
+
if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
|
|
227
|
+
if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
|
|
228
|
+
if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
|
|
229
|
+
entry.updatedAt = now;
|
|
230
|
+
entry.requeuedAt = now;
|
|
231
|
+
entry.requeueCount = (entry.requeueCount || 0) + 1;
|
|
232
|
+
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
233
|
+
writeQueue(meshId, queue);
|
|
234
|
+
return entry;
|
|
235
|
+
}
|
|
236
|
+
|
|
124
237
|
/**
|
|
125
238
|
* Update the status of the task currently assigned to a specific session.
|
|
126
239
|
*/
|
|
@@ -144,10 +257,24 @@ export function updateSessionTaskStatus(
|
|
|
144
257
|
}
|
|
145
258
|
|
|
146
259
|
export interface MeshWorkQueueStats {
|
|
260
|
+
total: number;
|
|
261
|
+
active: number;
|
|
262
|
+
historical: number;
|
|
147
263
|
pending: number;
|
|
148
264
|
assigned: number;
|
|
149
265
|
completed: number;
|
|
150
266
|
failed: number;
|
|
267
|
+
cancelled: number;
|
|
268
|
+
/** Source-of-truth active queue counters; only pending/assigned are live work. */
|
|
269
|
+
activeCounts: Record<MeshActiveTaskStatus, number>;
|
|
270
|
+
/** Terminal ledger records kept for audit/history; never count as active work. */
|
|
271
|
+
historicalCounts: Record<MeshHistoricalTaskStatus, number>;
|
|
272
|
+
activeAssignments: Array<{
|
|
273
|
+
id: string;
|
|
274
|
+
nodeId?: string;
|
|
275
|
+
sessionId?: string;
|
|
276
|
+
message: string;
|
|
277
|
+
}>;
|
|
151
278
|
}
|
|
152
279
|
|
|
153
280
|
/**
|
|
@@ -155,10 +282,36 @@ export interface MeshWorkQueueStats {
|
|
|
155
282
|
*/
|
|
156
283
|
export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
|
|
157
284
|
const queue = readQueue(meshId);
|
|
285
|
+
const pending = queue.filter(q => q.status === 'pending').length;
|
|
286
|
+
const assigned = queue.filter(q => q.status === 'assigned').length;
|
|
287
|
+
const completed = queue.filter(q => q.status === 'completed').length;
|
|
288
|
+
const failed = queue.filter(q => q.status === 'failed').length;
|
|
289
|
+
const cancelled = queue.filter(q => q.status === 'cancelled').length;
|
|
158
290
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
291
|
+
total: queue.length,
|
|
292
|
+
active: pending + assigned,
|
|
293
|
+
historical: completed + failed + cancelled,
|
|
294
|
+
pending,
|
|
295
|
+
assigned,
|
|
296
|
+
completed,
|
|
297
|
+
failed,
|
|
298
|
+
cancelled,
|
|
299
|
+
activeCounts: {
|
|
300
|
+
pending,
|
|
301
|
+
assigned,
|
|
302
|
+
},
|
|
303
|
+
historicalCounts: {
|
|
304
|
+
completed,
|
|
305
|
+
failed,
|
|
306
|
+
cancelled,
|
|
307
|
+
},
|
|
308
|
+
activeAssignments: queue
|
|
309
|
+
.filter(q => q.status === 'assigned')
|
|
310
|
+
.map(q => ({
|
|
311
|
+
id: q.id,
|
|
312
|
+
nodeId: q.assignedNodeId,
|
|
313
|
+
sessionId: q.assignedSessionId,
|
|
314
|
+
message: q.message,
|
|
315
|
+
})),
|
|
163
316
|
};
|
|
164
317
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export type P2pRelayFailureCode =
|
|
2
|
+
| 'p2p_unavailable'
|
|
3
|
+
| 'p2p_timeout'
|
|
4
|
+
| 'p2p_not_connected'
|
|
5
|
+
| 'p2p_datachannel_closed'
|
|
6
|
+
| 'p2p_no_route'
|
|
7
|
+
| 'p2p_daemon_offline'
|
|
8
|
+
| 'mesh_logic_or_provider_failure';
|
|
9
|
+
|
|
10
|
+
export interface P2pRelayFailureContext {
|
|
11
|
+
command?: string;
|
|
12
|
+
targetDaemonId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface P2pRelayFailureClassification {
|
|
16
|
+
code: P2pRelayFailureCode;
|
|
17
|
+
reason: string;
|
|
18
|
+
transport: 'p2p' | 'unknown';
|
|
19
|
+
recoverable: boolean;
|
|
20
|
+
retryRecommended: boolean;
|
|
21
|
+
nextAction: string;
|
|
22
|
+
noFallbackReason: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface P2pRelayFailurePayload extends P2pRelayFailureClassification {
|
|
26
|
+
success: false;
|
|
27
|
+
error: string;
|
|
28
|
+
command?: string;
|
|
29
|
+
targetDaemonId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NO_FALLBACK_REASON = 'Repo Mesh command/data-plane is P2P-only; WS/REST command fallback is intentionally disabled to preserve the transport boundary.';
|
|
33
|
+
const P2P_NEXT_ACTION = 'Check daemon/P2P health, wait briefly for connection establishment, then do one bounded retry or requeue the mesh task after clearing stale target session metadata.';
|
|
34
|
+
const NON_P2P_NEXT_ACTION = 'Inspect the provider/command error and fix the underlying logic or configuration before retrying.';
|
|
35
|
+
|
|
36
|
+
function messageFromError(error: unknown): string {
|
|
37
|
+
if (error instanceof Error) return error.message;
|
|
38
|
+
if (typeof error === 'string') return error;
|
|
39
|
+
if (error && typeof error === 'object') {
|
|
40
|
+
const candidate = (error as any).error ?? (error as any).message ?? (error as any).reason;
|
|
41
|
+
if (typeof candidate === 'string') return candidate;
|
|
42
|
+
}
|
|
43
|
+
return String(error || 'mesh relay command failed');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function classifyP2pRelayFailure(error: unknown, _context: P2pRelayFailureContext = {}): P2pRelayFailureClassification {
|
|
47
|
+
const message = messageFromError(error);
|
|
48
|
+
const lower = message.toLowerCase();
|
|
49
|
+
|
|
50
|
+
const hasP2pSignal = /p2p|datachannel|node-datachannel|webrtc|ice|mesh_relay_command|daemon_mesh_p2p_transport/i.test(message);
|
|
51
|
+
const hasFailureSignal = /unavailable|missing|failed|failure|timeout|timed out|not connected|closed|disconnected|offline|no route|route unavailable|cannot send|cannot establish/i.test(message);
|
|
52
|
+
|
|
53
|
+
// Validation errors that merely mention mesh_relay_command are not transport failures.
|
|
54
|
+
if (/requires targetdaemonid and command|providerpriority|no inference provider|permission denied|read-only|not a member/i.test(message)) {
|
|
55
|
+
return {
|
|
56
|
+
code: 'mesh_logic_or_provider_failure',
|
|
57
|
+
reason: 'mesh_logic_or_provider_failure',
|
|
58
|
+
transport: 'unknown',
|
|
59
|
+
recoverable: false,
|
|
60
|
+
retryRecommended: false,
|
|
61
|
+
nextAction: NON_P2P_NEXT_ACTION,
|
|
62
|
+
noFallbackReason: NO_FALLBACK_REASON,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let code: P2pRelayFailureCode | null = null;
|
|
67
|
+
let reason = '';
|
|
68
|
+
|
|
69
|
+
if (/timeout|timed out/i.test(message) && (hasP2pSignal || /mesh transport/i.test(message))) {
|
|
70
|
+
code = 'p2p_timeout';
|
|
71
|
+
reason = 'daemon_mesh_p2p_timeout';
|
|
72
|
+
} else if (/no route|route unavailable/i.test(message)) {
|
|
73
|
+
code = 'p2p_no_route';
|
|
74
|
+
reason = 'daemon_mesh_p2p_no_route';
|
|
75
|
+
} else if (/offline|not owned|not found|not connected to server/i.test(message) && /daemon|peer|target/i.test(message)) {
|
|
76
|
+
code = 'p2p_daemon_offline';
|
|
77
|
+
reason = 'daemon_mesh_target_offline';
|
|
78
|
+
} else if (/closed|disconnected/i.test(message) && (hasP2pSignal || /state changed/i.test(message))) {
|
|
79
|
+
code = 'p2p_datachannel_closed';
|
|
80
|
+
reason = 'daemon_mesh_p2p_datachannel_closed';
|
|
81
|
+
} else if (/not connected|cannot send|cannot establish/i.test(message) && hasP2pSignal) {
|
|
82
|
+
code = 'p2p_not_connected';
|
|
83
|
+
reason = 'daemon_mesh_p2p_not_connected';
|
|
84
|
+
} else if (hasP2pSignal && hasFailureSignal) {
|
|
85
|
+
code = 'p2p_unavailable';
|
|
86
|
+
reason = 'daemon_mesh_p2p_transport_unavailable';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!code) {
|
|
90
|
+
return {
|
|
91
|
+
code: 'mesh_logic_or_provider_failure',
|
|
92
|
+
reason: 'mesh_logic_or_provider_failure',
|
|
93
|
+
transport: 'unknown',
|
|
94
|
+
recoverable: false,
|
|
95
|
+
retryRecommended: false,
|
|
96
|
+
nextAction: NON_P2P_NEXT_ACTION,
|
|
97
|
+
noFallbackReason: NO_FALLBACK_REASON,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
code,
|
|
103
|
+
reason,
|
|
104
|
+
transport: 'p2p',
|
|
105
|
+
recoverable: true,
|
|
106
|
+
retryRecommended: true,
|
|
107
|
+
nextAction: P2P_NEXT_ACTION,
|
|
108
|
+
noFallbackReason: NO_FALLBACK_REASON,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isP2pRelayTransportFailure(error: unknown): boolean {
|
|
113
|
+
return classifyP2pRelayFailure(error).recoverable === true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildP2pRelayFailurePayload(error: unknown, context: P2pRelayFailureContext = {}): P2pRelayFailurePayload {
|
|
117
|
+
const classification = classifyP2pRelayFailure(error, context);
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
...classification,
|
|
121
|
+
error: messageFromError(error),
|
|
122
|
+
...(context.command ? { command: context.command } : {}),
|
|
123
|
+
...(context.targetDaemonId ? { targetDaemonId: context.targetDaemonId } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class P2pRelayFailureError extends Error {
|
|
128
|
+
code: P2pRelayFailureCode;
|
|
129
|
+
reason: string;
|
|
130
|
+
transport: 'p2p' | 'unknown';
|
|
131
|
+
recoverable: boolean;
|
|
132
|
+
retryRecommended: boolean;
|
|
133
|
+
nextAction: string;
|
|
134
|
+
noFallbackReason: string;
|
|
135
|
+
command?: string;
|
|
136
|
+
targetDaemonId?: string;
|
|
137
|
+
|
|
138
|
+
constructor(message: string, context: P2pRelayFailureContext = {}) {
|
|
139
|
+
super(message);
|
|
140
|
+
this.name = 'P2pRelayFailureError';
|
|
141
|
+
const payload = buildP2pRelayFailurePayload(message, context);
|
|
142
|
+
this.code = payload.code;
|
|
143
|
+
this.reason = payload.reason;
|
|
144
|
+
this.transport = payload.transport;
|
|
145
|
+
this.recoverable = payload.recoverable;
|
|
146
|
+
this.retryRecommended = payload.retryRecommended;
|
|
147
|
+
this.nextAction = payload.nextAction;
|
|
148
|
+
this.noFallbackReason = payload.noFallbackReason;
|
|
149
|
+
this.command = context.command;
|
|
150
|
+
this.targetDaemonId = context.targetDaemonId;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -35,6 +35,17 @@ type PersistableCliHistoryMessage = {
|
|
|
35
35
|
receivedAt?: number;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
type CompletedDebouncePending = {
|
|
39
|
+
chatTitle: string;
|
|
40
|
+
duration: number;
|
|
41
|
+
timestamp: number;
|
|
42
|
+
firstObservedAt: number;
|
|
43
|
+
loggedBlockReason?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const COMPLETED_FINALIZATION_RETRY_MS = 1000;
|
|
47
|
+
const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
|
|
48
|
+
|
|
38
49
|
const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
|
|
39
50
|
'image/png': '.png',
|
|
40
51
|
'image/jpeg': '.jpg',
|
|
@@ -103,6 +114,15 @@ function cleanupStaleMaterializedImages(dir: string): void {
|
|
|
103
114
|
} catch { /* dir may not exist or be inaccessible */ }
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
|
|
118
|
+
const buttons = (activeModal as any)?.buttons;
|
|
119
|
+
return Array.isArray(buttons) && buttons.some((button) => String(button || '').trim().length > 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isCliGeneratingLikeStatus(status: unknown): boolean {
|
|
123
|
+
return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
|
|
124
|
+
}
|
|
125
|
+
|
|
106
126
|
export function buildCliStructuredInputPrompt(
|
|
107
127
|
input: InputEnvelope,
|
|
108
128
|
options: { materializeDir?: string } = {},
|
|
@@ -511,6 +531,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
511
531
|
const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
|
|
512
532
|
|
|
513
533
|
const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
|
|
534
|
+
const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
|
|
535
|
+
? parsedStatus.status.trim()
|
|
536
|
+
: undefined;
|
|
537
|
+
const suppressStaleParsedBusyStatus = this.shouldSuppressStaleParsedBusyStatus(parsedStatus, adapterStatus);
|
|
514
538
|
|
|
515
539
|
if (parsedMessages.length > 0) {
|
|
516
540
|
const shouldSkipReplayPersist =
|
|
@@ -518,7 +542,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
518
542
|
&& adapterStatus.status === 'idle'
|
|
519
543
|
&& parsedStatus?.status === 'idle';
|
|
520
544
|
let messagesToSave = parsedMessages;
|
|
521
|
-
if ((
|
|
545
|
+
if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
|
|
522
546
|
const lastIdx = messagesToSave.length - 1;
|
|
523
547
|
if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
|
|
524
548
|
messagesToSave = messagesToSave.slice(0, lastIdx);
|
|
@@ -553,6 +577,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
553
577
|
summaryMetadata: this.summaryMetadata as any,
|
|
554
578
|
controlValues: this.controlValues,
|
|
555
579
|
});
|
|
580
|
+
const activeChatStatus = parseErrorMessage
|
|
581
|
+
? 'error'
|
|
582
|
+
: autoApproveActive && parsedStatus?.status === 'waiting_approval'
|
|
583
|
+
? 'generating'
|
|
584
|
+
: (adapterStatus.status !== 'idle'
|
|
585
|
+
? visibleStatus
|
|
586
|
+
: (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
|
|
556
587
|
|
|
557
588
|
return {
|
|
558
589
|
type: this.type,
|
|
@@ -563,13 +594,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
563
594
|
activeChat: {
|
|
564
595
|
id: `${this.type}_${this.workingDir}`,
|
|
565
596
|
title: parsedStatus?.title || dirName,
|
|
566
|
-
status:
|
|
567
|
-
? 'error'
|
|
568
|
-
: autoApproveActive && parsedStatus?.status === 'waiting_approval'
|
|
569
|
-
? 'generating'
|
|
570
|
-
: (adapterStatus.status !== 'idle'
|
|
571
|
-
? visibleStatus
|
|
572
|
-
: (parsedStatus?.status || visibleStatus)),
|
|
597
|
+
status: activeChatStatus,
|
|
573
598
|
messages: mergedMessages,
|
|
574
599
|
activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
|
|
575
600
|
inputContent: '',
|
|
@@ -680,7 +705,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
680
705
|
}
|
|
681
706
|
|
|
682
707
|
private completedDebounceTimer: NodeJS.Timeout | null = null;
|
|
683
|
-
private completedDebouncePending:
|
|
708
|
+
private completedDebouncePending: CompletedDebouncePending | null = null;
|
|
684
709
|
|
|
685
710
|
private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
|
|
686
711
|
const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
|
|
@@ -709,6 +734,119 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
709
734
|
this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
|
|
710
735
|
}
|
|
711
736
|
|
|
737
|
+
private completionHasFinalAssistantMessage(messages: unknown): boolean {
|
|
738
|
+
const visibleMessages = (Array.isArray(messages) ? messages : [])
|
|
739
|
+
.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
|
|
740
|
+
const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
|
|
741
|
+
const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
|
|
742
|
+
const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
|
|
743
|
+
return role === 'assistant' && !!content;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private hasAdapterPendingResponse(): boolean {
|
|
747
|
+
const adapterAny = this.adapter as any;
|
|
748
|
+
if (adapterAny?.isWaitingForResponse === true) return true;
|
|
749
|
+
if (adapterAny?.currentTurnScope) return true;
|
|
750
|
+
try {
|
|
751
|
+
if (typeof this.adapter.isProcessing === 'function' && this.adapter.isProcessing()) return true;
|
|
752
|
+
} catch { /* defensive: status rendering must not fail because of adapter diagnostics */ }
|
|
753
|
+
try {
|
|
754
|
+
const partial = typeof this.adapter.getPartialResponse === 'function'
|
|
755
|
+
? this.adapter.getPartialResponse()
|
|
756
|
+
: '';
|
|
757
|
+
if (typeof partial === 'string' && partial.trim()) return true;
|
|
758
|
+
} catch { /* defensive: missing partial means no pending response evidence */ }
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private shouldSuppressStaleParsedBusyStatus(parsedStatus: any, adapterStatus: any): boolean {
|
|
763
|
+
const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
|
|
764
|
+
const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
|
|
765
|
+
if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
|
|
766
|
+
if (adapterRawStatus !== 'idle') return false;
|
|
767
|
+
if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
|
|
768
|
+
return !this.hasAdapterPendingResponse();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
|
|
772
|
+
if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
|
|
773
|
+
|
|
774
|
+
const adapterAny = this.adapter as any;
|
|
775
|
+
if (adapterAny?.isWaitingForResponse === true) return 'adapter_waiting_for_response';
|
|
776
|
+
if (adapterAny?.currentTurnScope) return 'adapter_turn_scope_active';
|
|
777
|
+
|
|
778
|
+
const partial = typeof this.adapter.getPartialResponse === 'function'
|
|
779
|
+
? this.adapter.getPartialResponse()
|
|
780
|
+
: '';
|
|
781
|
+
if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
|
|
782
|
+
|
|
783
|
+
let parsed: any;
|
|
784
|
+
try {
|
|
785
|
+
parsed = this.adapter.getScriptParsedStatus();
|
|
786
|
+
} catch (error: any) {
|
|
787
|
+
return `parse_error:${error?.message || String(error)}`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
|
|
791
|
+
if (parsedStatus !== 'idle') return `parsed_status:${parsedStatus}`;
|
|
792
|
+
if (parsed?.activeModal || parsed?.modal) return 'parsed_modal_active';
|
|
793
|
+
if (!this.completionHasFinalAssistantMessage(parsed?.messages)) return 'missing_final_assistant';
|
|
794
|
+
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private scheduleCompletedDebounceFlush(delayMs: number): void {
|
|
799
|
+
if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
|
|
800
|
+
this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private flushCompletedDebounceIfFinalized(): void {
|
|
804
|
+
const pending = this.completedDebouncePending;
|
|
805
|
+
if (!pending) {
|
|
806
|
+
this.completedDebounceTimer = null;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const latestStatus = this.adapter.getStatus({ allowParse: false });
|
|
811
|
+
const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
812
|
+
const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
|
|
813
|
+
if (latestVisibleStatus !== 'idle') {
|
|
814
|
+
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
|
|
815
|
+
this.completedDebouncePending = null;
|
|
816
|
+
this.completedDebounceTimer = null;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
|
|
821
|
+
if (blockReason) {
|
|
822
|
+
const waitedMs = Date.now() - pending.firstObservedAt;
|
|
823
|
+
if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
|
|
824
|
+
if (pending.loggedBlockReason !== blockReason) {
|
|
825
|
+
LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
|
|
826
|
+
pending.loggedBlockReason = blockReason;
|
|
827
|
+
}
|
|
828
|
+
this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
|
|
832
|
+
this.completedDebouncePending = null;
|
|
833
|
+
this.completedDebounceTimer = null;
|
|
834
|
+
this.generatingStartedAt = 0;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
|
|
839
|
+
this.pushEvent({
|
|
840
|
+
event: 'agent:generating_completed',
|
|
841
|
+
chatTitle: pending.chatTitle,
|
|
842
|
+
duration: pending.duration,
|
|
843
|
+
timestamp: pending.timestamp,
|
|
844
|
+
});
|
|
845
|
+
this.completedDebouncePending = null;
|
|
846
|
+
this.completedDebounceTimer = null;
|
|
847
|
+
this.generatingStartedAt = 0;
|
|
848
|
+
}
|
|
849
|
+
|
|
712
850
|
private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
|
|
713
851
|
const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
714
852
|
// Guard re-entry: onStatusChange/getState can observe the same modal multiple
|
|
@@ -811,28 +949,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
811
949
|
this.generatingDebouncePending = null;
|
|
812
950
|
this.generatingStartedAt = 0;
|
|
813
951
|
} else {
|
|
814
|
-
// Debounce completed
|
|
815
|
-
|
|
816
|
-
this.completedDebouncePending = { chatTitle, duration, timestamp: now };
|
|
817
|
-
this.
|
|
818
|
-
if (this.completedDebouncePending) {
|
|
819
|
-
const latestStatus = this.adapter.getStatus({ allowParse: false });
|
|
820
|
-
const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
821
|
-
const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
|
|
822
|
-
if (latestVisibleStatus !== 'idle') {
|
|
823
|
-
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
|
|
824
|
-
this.completedDebouncePending = null;
|
|
825
|
-
this.completedDebounceTimer = null;
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
LOG.info('CLI', `[${this.type}] completed in ${this.completedDebouncePending.duration}s`);
|
|
829
|
-
this.pushEvent({ event: 'agent:generating_completed', ...this.completedDebouncePending });
|
|
830
|
-
this.completedDebouncePending = null;
|
|
831
|
-
this.generatingStartedAt = 0;
|
|
832
|
-
}
|
|
833
|
-
this.completedDebounceTimer = null;
|
|
834
|
-
}, 3000);
|
|
952
|
+
// Debounce completed, then require the rich transcript path that read_chat
|
|
953
|
+
// uses to show an idle turn whose last user-facing message is assistant.
|
|
954
|
+
this.completedDebouncePending = { chatTitle, duration, timestamp: now, firstObservedAt: now };
|
|
955
|
+
this.scheduleCompletedDebounceFlush(3000);
|
|
835
956
|
}
|
|
957
|
+
} else if (newStatus === 'idle' && this.lastStatus === 'starting') {
|
|
958
|
+
this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
|
|
836
959
|
} else if (newStatus === 'stopped') {
|
|
837
960
|
// Cancel any pending debounce
|
|
838
961
|
if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -224,6 +224,8 @@ export interface LocalMeshNodeEntry {
|
|
|
224
224
|
workspace: string;
|
|
225
225
|
repoRoot?: string;
|
|
226
226
|
daemonId?: string;
|
|
227
|
+
/** Machine registry ID that owns this workspace, when known. */
|
|
228
|
+
machineId?: string;
|
|
227
229
|
userOverrides: Partial<RepoMeshNodeCapabilities>;
|
|
228
230
|
policy: RepoMeshNodePolicy;
|
|
229
231
|
/** For single-machine mesh: same daemon, different worktree */
|