@adhdev/daemon-core 0.9.82-rc.384 → 0.9.82-rc.386
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-adapter-types.d.ts +12 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +11 -0
- package/dist/index.js +71 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +71 -7
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +1 -1
- package/dist/mesh/mesh-reconcile-loop.d.ts +56 -0
- package/dist/providers/cli-provider-instance.d.ts +8 -0
- package/dist/providers/spec/fsm-driver.d.ts +10 -0
- package/package.json +2 -2
- package/src/cli-adapter-types.ts +12 -0
- package/src/cli-adapters/provider-cli-shared.ts +11 -0
- package/src/commands/high-family/mesh-events.ts +13 -1
- package/src/mesh/mesh-events.ts +2 -0
- package/src/mesh/mesh-reconcile-loop.ts +84 -0
- package/src/providers/cli-provider-instance.ts +43 -1
- package/src/providers/spec/cli-adapter.ts +6 -1
- package/src/providers/spec/fsm-driver.ts +12 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
2
2
|
export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, } from './mesh-events-pending.js';
|
|
3
3
|
export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
|
|
4
|
-
export { setupMeshReconcileLoop, runMeshReconcileTick, } from './mesh-reconcile-loop.js';
|
|
4
|
+
export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
|
|
5
5
|
export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
|
|
6
6
|
export { tryAssignQueueTask, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
|
|
@@ -1,4 +1,60 @@
|
|
|
1
1
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
|
+
/**
|
|
3
|
+
* DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
|
|
4
|
+
* queue-drain caller (the MCP `get_pending_mesh_events` poll) may safely consume
|
|
5
|
+
* pending coordinator events — i.e. whether there is a surface that will actually
|
|
6
|
+
* deliver them.
|
|
7
|
+
*
|
|
8
|
+
* Root cause being guarded: `get_pending_mesh_events` marks rows drained=1
|
|
9
|
+
* atomically and unconditionally. When the live CLI coordinator for the mesh is
|
|
10
|
+
* GENERATING (or modal-parked), the reconcile loop correctly HOLDS its terminal
|
|
11
|
+
* events (drained=0) for the coordinator's next idle tick — but a concurrent MCP
|
|
12
|
+
* poll draining the SAME queue consumes those held rows into a tool result that
|
|
13
|
+
* the busy coordinator never surfaces as a turn, so the completion is lost
|
|
14
|
+
* forever (drained=1, never re-queued). The reconcile loop is the authoritative
|
|
15
|
+
* delivery path for a live CLI coordinator; the MCP poll must defer to it.
|
|
16
|
+
*
|
|
17
|
+
* Returns:
|
|
18
|
+
* - hasLiveCliCoordinator: a CLI session with meshCoordinatorFor === meshId
|
|
19
|
+
* exists on this daemon (the reconcile loop owns its delivery).
|
|
20
|
+
* - deliverableNow: there is an IDLE live CLI coordinator (reconcile would
|
|
21
|
+
* full-drain into it) — draining now is safe and equivalent.
|
|
22
|
+
* - holdForReconcile: a live CLI coordinator exists but is non-idle
|
|
23
|
+
* (generating / modal-parked). The MCP poll MUST NOT drain; the reconcile
|
|
24
|
+
* loop holds the events undrained and injects them on the next idle tick.
|
|
25
|
+
*
|
|
26
|
+
* A mesh with NO live CLI coordinator on this daemon is a pure stdio MCP / LLM
|
|
27
|
+
* coordinator: the MCP tool result IS the only surface, so the poll legitimately
|
|
28
|
+
* drains (holdForReconcile=false). No regression to that path.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveCoordinatorDrainDeliverability(components: Pick<DaemonComponents, 'instanceManager'>, meshId: string): {
|
|
31
|
+
hasLiveCliCoordinator: boolean;
|
|
32
|
+
deliverableNow: boolean;
|
|
33
|
+
holdForReconcile: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* DRAIN-WITHOUT-INJECT guard for the `get_pending_mesh_events` daemon handler.
|
|
37
|
+
*
|
|
38
|
+
* Decides whether an incoming pending-events DRAIN must be held (return nothing,
|
|
39
|
+
* leave rows drained=0) because the only surface for those events is a LOCAL live
|
|
40
|
+
* CLI coordinator that is currently busy (generating / modal-parked) — in which
|
|
41
|
+
* case the reconcile loop owns delivery on the coordinator's next idle tick, and
|
|
42
|
+
* the poll draining them now would lose them.
|
|
43
|
+
*
|
|
44
|
+
* The hold applies ONLY when BOTH:
|
|
45
|
+
* 1) a live CLI coordinator for this mesh on THIS daemon is non-idle, AND
|
|
46
|
+
* 2) the drain is targeted at THIS daemon (the requested coordinatorDaemonId is
|
|
47
|
+
* empty/broadcast, or matches one of this daemon's id forms).
|
|
48
|
+
*
|
|
49
|
+
* A REMOTE coordinator pulling our worker's events passes its own (remote)
|
|
50
|
+
* coordinatorDaemonId — condition (2) is false — so the drain proceeds and the
|
|
51
|
+
* remote pull is never blocked by our local coordinator's busy state. A pure
|
|
52
|
+
* stdio MCP coordinator (no live CLI session) never satisfies (1), so its tool
|
|
53
|
+
* result remains the surface and the drain proceeds. No regression to either.
|
|
54
|
+
*/
|
|
55
|
+
export declare function shouldHoldPendingDrainForBusyLocalCoordinator(components: Pick<DaemonComponents, 'instanceManager'> & {
|
|
56
|
+
statusInstanceId?: string;
|
|
57
|
+
}, meshId: string, requestedCoordinatorDaemonId?: string | null): boolean;
|
|
2
58
|
export declare function runMeshReconcileTick(components: DaemonComponents): Promise<void>;
|
|
3
59
|
export declare function __resetUnresolvedForwardRejectionCountsForTests(): void;
|
|
4
60
|
interface ReconcileLoopHandle {
|
|
@@ -66,6 +66,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
66
66
|
private context;
|
|
67
67
|
private events;
|
|
68
68
|
private lastStatus;
|
|
69
|
+
private agentReadyEmitted;
|
|
69
70
|
private generatingStartedAt;
|
|
70
71
|
private settings;
|
|
71
72
|
private monitor;
|
|
@@ -220,6 +221,13 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
220
221
|
* the approval decision; the next real PTY frame refreshes visible status.
|
|
221
222
|
*/
|
|
222
223
|
private recheckAutoApproveSettled;
|
|
224
|
+
/**
|
|
225
|
+
* Emit the queue-claim agent:ready event at most once per session. Both the
|
|
226
|
+
* boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
|
|
227
|
+
* agentReadyEmitted guard ensures the second caller is a no-op so a worker is
|
|
228
|
+
* never claimed twice and a queued task is never double-dispatched.
|
|
229
|
+
*/
|
|
230
|
+
private emitAgentReadyOnce;
|
|
223
231
|
private detectStatusTransition;
|
|
224
232
|
private pushEvent;
|
|
225
233
|
private flushEvents;
|
|
@@ -134,6 +134,7 @@ export interface ISpecDriver {
|
|
|
134
134
|
}> | null;
|
|
135
135
|
getLastBusyAt(): number;
|
|
136
136
|
hasIdleHoldPending(): boolean;
|
|
137
|
+
hasSeenReady(): boolean;
|
|
137
138
|
getCompletionIdleDebounceState(): {
|
|
138
139
|
active: boolean;
|
|
139
140
|
ageMs: number;
|
|
@@ -264,6 +265,15 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
264
265
|
* working without branching on driver type. */
|
|
265
266
|
getLastBusyAt(): number;
|
|
266
267
|
hasIdleHoldPending(): boolean;
|
|
268
|
+
/**
|
|
269
|
+
* True once the machine has reached its first non-initial idle state (the
|
|
270
|
+
* prompt is genuinely drawn — see maybeMarkReady). The cli-adapter surfaces
|
|
271
|
+
* this on its idle status so CliProviderInstance can re-arm the queue-claim
|
|
272
|
+
* agent:ready on the first genuine ready, independent of the boot-time
|
|
273
|
+
* starting→idle one-shot (which is consumed too early for specs whose
|
|
274
|
+
* initial state already reports idle).
|
|
275
|
+
*/
|
|
276
|
+
hasSeenReady(): boolean;
|
|
267
277
|
getCompletionIdleDebounceState(): {
|
|
268
278
|
active: boolean;
|
|
269
279
|
ageMs: number;
|
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.386",
|
|
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.386",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
package/src/cli-adapter-types.ts
CHANGED
|
@@ -28,6 +28,18 @@ export interface CliAdapterStatus {
|
|
|
28
28
|
providerSessionId?: string;
|
|
29
29
|
errorMessage?: string;
|
|
30
30
|
errorReason?: string;
|
|
31
|
+
/**
|
|
32
|
+
* FSM-spec adapters only: true once the driver has observed its first
|
|
33
|
+
* non-initial idle state (the prompt is genuinely drawn — see
|
|
34
|
+
* FsmDriver.maybeMarkReady / readySeenOnce). Used by CliProviderInstance to
|
|
35
|
+
* re-arm the queue-claim `agent:ready` event on the first genuine ready,
|
|
36
|
+
* independent of the boot-time starting→idle one-shot. That one-shot is
|
|
37
|
+
* consumed too early for providers whose INITIAL FSM state already reports
|
|
38
|
+
* status 'idle' (e.g. antigravity-cli), so without this re-arm the worker
|
|
39
|
+
* never claims its queued task and the coordinator relaunch-loops. Absent
|
|
40
|
+
* (undefined) for non-FSM adapters — they keep the boot one-shot behavior.
|
|
41
|
+
*/
|
|
42
|
+
fsmReadySeen?: boolean;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
export interface AcpAdapterHandle {
|
|
@@ -60,6 +60,17 @@ export interface CliSessionStatus {
|
|
|
60
60
|
errorMessage?: string;
|
|
61
61
|
errorReason?: string;
|
|
62
62
|
providerSessionId?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Spec/FSM adapters only (SpecCliAdapter): true once the driver has observed
|
|
65
|
+
* its first non-initial idle state — the prompt is genuinely drawn. The
|
|
66
|
+
* legacy ProviderCliAdapter never sets it (undefined). CliProviderInstance
|
|
67
|
+
* uses it to re-arm the queue-claim agent:ready on the first genuine ready,
|
|
68
|
+
* independent of the boot-time starting→idle one-shot (which is consumed too
|
|
69
|
+
* early for specs whose initial state already reports status 'idle', e.g.
|
|
70
|
+
* antigravity-cli — without the re-arm the worker never claims its queued
|
|
71
|
+
* task and the coordinator relaunch-loops).
|
|
72
|
+
*/
|
|
73
|
+
fsmReadySeen?: boolean;
|
|
63
74
|
/**
|
|
64
75
|
* Timestamp (ms) of the most recent raw PTY output chunk. Advances on every
|
|
65
76
|
* byte the process emits, including tool/build output that produces no
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import {
|
|
11
11
|
handleMeshForwardEvent,
|
|
12
12
|
drainPendingMeshCoordinatorEvents,
|
|
13
|
+
shouldHoldPendingDrainForBusyLocalCoordinator,
|
|
13
14
|
} from '../../mesh/mesh-events.js';
|
|
14
15
|
import { normalizeInteractivePromptResponse } from '../../providers/types/interactive-prompt.js';
|
|
15
16
|
import type { HighFamilyContext, HighFamilyHandler } from './types.js';
|
|
@@ -19,7 +20,7 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
|
|
|
19
20
|
return handleMeshForwardEvent({ instanceManager: ctx.deps.instanceManager } as any, args as Record<string, unknown>);
|
|
20
21
|
},
|
|
21
22
|
|
|
22
|
-
get_pending_mesh_events: async (
|
|
23
|
+
get_pending_mesh_events: async (ctx: HighFamilyContext, args: any) => {
|
|
23
24
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
24
25
|
// (B3) Respect coordinatorDaemonId when the caller declares it
|
|
25
26
|
// so unicast events route to the right coordinator instead of
|
|
@@ -27,6 +28,17 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
|
|
|
27
28
|
const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
|
|
28
29
|
? args.coordinatorDaemonId.trim()
|
|
29
30
|
: undefined;
|
|
31
|
+
// DRAIN-WITHOUT-INJECT guard: when a LOCAL live CLI coordinator for this mesh is
|
|
32
|
+
// busy (generating / modal-parked), the reconcile loop is HOLDING its terminal
|
|
33
|
+
// events (drained=0) for the coordinator's next idle tick. Draining here would
|
|
34
|
+
// consume those held rows (drained=1) into an MCP tool result the busy coordinator
|
|
35
|
+
// never surfaces as a turn — losing the completion forever. Defer to the reconcile
|
|
36
|
+
// loop: return nothing, leaving the rows undrained for its idle-tick delivery. A
|
|
37
|
+
// remote pull (foreign coordinatorDaemonId) or a pure stdio MCP coordinator (no live
|
|
38
|
+
// CLI session) is NOT held — see shouldHoldPendingDrainForBusyLocalCoordinator.
|
|
39
|
+
if (meshId && shouldHoldPendingDrainForBusyLocalCoordinator(ctx.deps, meshId, coordinatorDaemonId)) {
|
|
40
|
+
return { success: true, events: [], heldForBusyLocalCoordinator: true };
|
|
41
|
+
}
|
|
30
42
|
const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
|
|
31
43
|
return { success: true, events };
|
|
32
44
|
},
|
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -20,6 +20,8 @@ export {
|
|
|
20
20
|
export {
|
|
21
21
|
setupMeshReconcileLoop,
|
|
22
22
|
runMeshReconcileTick,
|
|
23
|
+
resolveCoordinatorDrainDeliverability,
|
|
24
|
+
shouldHoldPendingDrainForBusyLocalCoordinator,
|
|
23
25
|
} from './mesh-reconcile-loop.js';
|
|
24
26
|
|
|
25
27
|
export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
|
|
@@ -248,6 +248,90 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
|
248
248
|
return out;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
|
|
253
|
+
* queue-drain caller (the MCP `get_pending_mesh_events` poll) may safely consume
|
|
254
|
+
* pending coordinator events — i.e. whether there is a surface that will actually
|
|
255
|
+
* deliver them.
|
|
256
|
+
*
|
|
257
|
+
* Root cause being guarded: `get_pending_mesh_events` marks rows drained=1
|
|
258
|
+
* atomically and unconditionally. When the live CLI coordinator for the mesh is
|
|
259
|
+
* GENERATING (or modal-parked), the reconcile loop correctly HOLDS its terminal
|
|
260
|
+
* events (drained=0) for the coordinator's next idle tick — but a concurrent MCP
|
|
261
|
+
* poll draining the SAME queue consumes those held rows into a tool result that
|
|
262
|
+
* the busy coordinator never surfaces as a turn, so the completion is lost
|
|
263
|
+
* forever (drained=1, never re-queued). The reconcile loop is the authoritative
|
|
264
|
+
* delivery path for a live CLI coordinator; the MCP poll must defer to it.
|
|
265
|
+
*
|
|
266
|
+
* Returns:
|
|
267
|
+
* - hasLiveCliCoordinator: a CLI session with meshCoordinatorFor === meshId
|
|
268
|
+
* exists on this daemon (the reconcile loop owns its delivery).
|
|
269
|
+
* - deliverableNow: there is an IDLE live CLI coordinator (reconcile would
|
|
270
|
+
* full-drain into it) — draining now is safe and equivalent.
|
|
271
|
+
* - holdForReconcile: a live CLI coordinator exists but is non-idle
|
|
272
|
+
* (generating / modal-parked). The MCP poll MUST NOT drain; the reconcile
|
|
273
|
+
* loop holds the events undrained and injects them on the next idle tick.
|
|
274
|
+
*
|
|
275
|
+
* A mesh with NO live CLI coordinator on this daemon is a pure stdio MCP / LLM
|
|
276
|
+
* coordinator: the MCP tool result IS the only surface, so the poll legitimately
|
|
277
|
+
* drains (holdForReconcile=false). No regression to that path.
|
|
278
|
+
*/
|
|
279
|
+
export function resolveCoordinatorDrainDeliverability(
|
|
280
|
+
components: Pick<DaemonComponents, 'instanceManager'>,
|
|
281
|
+
meshId: string,
|
|
282
|
+
): { hasLiveCliCoordinator: boolean; deliverableNow: boolean; holdForReconcile: boolean } {
|
|
283
|
+
const coordinators = findLiveCoordinators(components as DaemonComponents).filter(c => c.meshId === meshId);
|
|
284
|
+
if (coordinators.length === 0) {
|
|
285
|
+
return { hasLiveCliCoordinator: false, deliverableNow: false, holdForReconcile: false };
|
|
286
|
+
}
|
|
287
|
+
const hasIdle = coordinators.some(c => c.idle);
|
|
288
|
+
return {
|
|
289
|
+
hasLiveCliCoordinator: true,
|
|
290
|
+
deliverableNow: hasIdle,
|
|
291
|
+
// A live CLI coordinator exists but none is idle → the reconcile loop is
|
|
292
|
+
// holding the events; the poll must not steal them.
|
|
293
|
+
holdForReconcile: !hasIdle,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* DRAIN-WITHOUT-INJECT guard for the `get_pending_mesh_events` daemon handler.
|
|
299
|
+
*
|
|
300
|
+
* Decides whether an incoming pending-events DRAIN must be held (return nothing,
|
|
301
|
+
* leave rows drained=0) because the only surface for those events is a LOCAL live
|
|
302
|
+
* CLI coordinator that is currently busy (generating / modal-parked) — in which
|
|
303
|
+
* case the reconcile loop owns delivery on the coordinator's next idle tick, and
|
|
304
|
+
* the poll draining them now would lose them.
|
|
305
|
+
*
|
|
306
|
+
* The hold applies ONLY when BOTH:
|
|
307
|
+
* 1) a live CLI coordinator for this mesh on THIS daemon is non-idle, AND
|
|
308
|
+
* 2) the drain is targeted at THIS daemon (the requested coordinatorDaemonId is
|
|
309
|
+
* empty/broadcast, or matches one of this daemon's id forms).
|
|
310
|
+
*
|
|
311
|
+
* A REMOTE coordinator pulling our worker's events passes its own (remote)
|
|
312
|
+
* coordinatorDaemonId — condition (2) is false — so the drain proceeds and the
|
|
313
|
+
* remote pull is never blocked by our local coordinator's busy state. A pure
|
|
314
|
+
* stdio MCP coordinator (no live CLI session) never satisfies (1), so its tool
|
|
315
|
+
* result remains the surface and the drain proceeds. No regression to either.
|
|
316
|
+
*/
|
|
317
|
+
export function shouldHoldPendingDrainForBusyLocalCoordinator(
|
|
318
|
+
components: Pick<DaemonComponents, 'instanceManager'> & { statusInstanceId?: string },
|
|
319
|
+
meshId: string,
|
|
320
|
+
requestedCoordinatorDaemonId?: string | null,
|
|
321
|
+
): boolean {
|
|
322
|
+
if (!meshId) return false;
|
|
323
|
+
const deliverability = resolveCoordinatorDrainDeliverability(components, meshId);
|
|
324
|
+
if (!deliverability.holdForReconcile) return false;
|
|
325
|
+
// The local CLI coordinator is busy. Hold only when the drain is for THIS daemon.
|
|
326
|
+
const requested = readNonEmptyString(requestedCoordinatorDaemonId);
|
|
327
|
+
if (!requested) return true; // broadcast drain → would consume the held local events
|
|
328
|
+
const localIds = expandDaemonIdForms([
|
|
329
|
+
readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId),
|
|
330
|
+
readNonEmptyString(loadConfig().machineId),
|
|
331
|
+
]);
|
|
332
|
+
return localIds.some(id => daemonIdsEquivalent(id, requested));
|
|
333
|
+
}
|
|
334
|
+
|
|
251
335
|
// Inject a drained pending event into a live coordinator session. Force-inject
|
|
252
336
|
// events carry force:true so they bypass the busy send-guard and land in the PTY
|
|
253
337
|
// even while the coordinator is generating (see shouldForceInjectMeshEvent).
|
|
@@ -430,6 +430,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
430
430
|
private context: InstanceContext | null = null;
|
|
431
431
|
private events: ProviderEvent[] = [];
|
|
432
432
|
private lastStatus: string = 'starting';
|
|
433
|
+
// Idempotency guard for the queue-claim agent:ready event. agent:ready is the
|
|
434
|
+
// sole signal the mesh coordinator's tryAssignQueueTask waits on to hand a
|
|
435
|
+
// queued task to this worker. It is emitted in two places: the boot-time
|
|
436
|
+
// starting→idle one-shot, and the readySeen re-arm below. This flag makes the
|
|
437
|
+
// event fire AT MOST ONCE per session so a worker is never claimed twice and a
|
|
438
|
+
// queued task is never double-dispatched/double-injected. Whichever path fires
|
|
439
|
+
// first sets it; the other becomes a no-op.
|
|
440
|
+
private agentReadyEmitted = false;
|
|
433
441
|
private generatingStartedAt: number = 0;
|
|
434
442
|
private settings: Record<string, any> = {};
|
|
435
443
|
private monitor: StatusMonitor;
|
|
@@ -1920,6 +1928,18 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1920
1928
|
} catch { /* adapter gone / transient — next frame retries */ }
|
|
1921
1929
|
}
|
|
1922
1930
|
|
|
1931
|
+
/**
|
|
1932
|
+
* Emit the queue-claim agent:ready event at most once per session. Both the
|
|
1933
|
+
* boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
|
|
1934
|
+
* agentReadyEmitted guard ensures the second caller is a no-op so a worker is
|
|
1935
|
+
* never claimed twice and a queued task is never double-dispatched.
|
|
1936
|
+
*/
|
|
1937
|
+
private emitAgentReadyOnce(chatTitle: string, now: number): void {
|
|
1938
|
+
if (this.agentReadyEmitted) return;
|
|
1939
|
+
this.agentReadyEmitted = true;
|
|
1940
|
+
this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1923
1943
|
private detectStatusTransition(): void {
|
|
1924
1944
|
const now = Date.now();
|
|
1925
1945
|
// Status-change handling is a hot path: PTY output can fire it many times
|
|
@@ -2142,7 +2162,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2142
2162
|
this.scheduleCompletedDebounceFlush(flushDelay);
|
|
2143
2163
|
}
|
|
2144
2164
|
} else if (newStatus === 'idle' && this.lastStatus === 'starting') {
|
|
2145
|
-
this.
|
|
2165
|
+
this.emitAgentReadyOnce(chatTitle, now);
|
|
2146
2166
|
} else if (newStatus === 'error') {
|
|
2147
2167
|
if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
|
|
2148
2168
|
this.generatingDebouncePending = null;
|
|
@@ -2171,6 +2191,28 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2171
2191
|
this.lastStatus = newStatus;
|
|
2172
2192
|
}
|
|
2173
2193
|
|
|
2194
|
+
// Re-arm the queue-claim agent:ready on the FSM's first GENUINE ready.
|
|
2195
|
+
//
|
|
2196
|
+
// The boot-time starting→idle one-shot above is the historical claim
|
|
2197
|
+
// trigger, but it is consumed too early for FSM-spec providers whose
|
|
2198
|
+
// INITIAL state already reports status 'idle' (e.g. antigravity-cli): the
|
|
2199
|
+
// adapter reports idle before maybeMarkReady has fired, so lastStatus
|
|
2200
|
+
// advances starting→idle while the prompt is not yet drawn and the worker
|
|
2201
|
+
// cannot yet claim. Subsequent state-driven idle frames are idle→idle (no
|
|
2202
|
+
// status change), so the one-shot never re-fires and the worker strands its
|
|
2203
|
+
// queued task — the coordinator then relaunch-loops every ~90s.
|
|
2204
|
+
//
|
|
2205
|
+
// The adapter surfaces fsmReadySeen=true exactly when the FSM reaches its
|
|
2206
|
+
// first non-initial idle (the prompt is genuinely up). On that signal we
|
|
2207
|
+
// emit agent:ready once more. emitAgentReadyOnce is idempotent (guarded by
|
|
2208
|
+
// agentReadyEmitted), so providers whose boot one-shot already landed on a
|
|
2209
|
+
// real ready (claude-cli / codex-cli / hermes-cli, which use a startup
|
|
2210
|
+
// grace and whose initial state is not idle) treat this as a no-op — no
|
|
2211
|
+
// double claim, no double task injection.
|
|
2212
|
+
if (newStatus === 'idle' && adapterStatus.fsmReadySeen === true && !this.agentReadyEmitted) {
|
|
2213
|
+
this.emitAgentReadyOnce(chatTitle, now);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2174
2216
|
this.applyProviderResponse(parsedStatus, {
|
|
2175
2217
|
phase: (newStatus === 'idle' && (previousStatus === 'generating' || previousStatus === 'waiting_approval'))
|
|
2176
2218
|
? 'turn_completed'
|
|
@@ -206,7 +206,12 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
206
206
|
if (state.status === 'generating') {
|
|
207
207
|
return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
|
|
208
208
|
}
|
|
209
|
-
|
|
209
|
+
// fsmReadySeen lets CliProviderInstance re-arm the queue-claim agent:ready
|
|
210
|
+
// on the first genuine ready (prompt drawn), independent of the boot-time
|
|
211
|
+
// starting→idle one-shot that the provider-instance otherwise relies on.
|
|
212
|
+
// Surfaced only on idle so the provider-instance fires agent:ready exactly
|
|
213
|
+
// when the worker is actually ready to claim.
|
|
214
|
+
return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, fsmReadySeen: this.driver.hasSeenReady?.() ?? false, ...sessionFields };
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
private maybeRefreshNativeHistory(): void {
|
|
@@ -125,6 +125,7 @@ export interface ISpecDriver {
|
|
|
125
125
|
getSections(): Array<{ id: string; text: string }> | null;
|
|
126
126
|
getLastBusyAt(): number;
|
|
127
127
|
hasIdleHoldPending(): boolean;
|
|
128
|
+
hasSeenReady(): boolean;
|
|
128
129
|
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
|
|
129
130
|
getFsmDebug?(): unknown;
|
|
130
131
|
getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
|
|
@@ -466,6 +467,17 @@ export class FsmDriver implements ISpecDriver {
|
|
|
466
467
|
// Report true while any outgoing transition is hold-blocked.
|
|
467
468
|
return (this.lastFsmEval?.transitions ?? []).some(t => !t.holdSatisfied && t.condResult);
|
|
468
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* True once the machine has reached its first non-initial idle state (the
|
|
472
|
+
* prompt is genuinely drawn — see maybeMarkReady). The cli-adapter surfaces
|
|
473
|
+
* this on its idle status so CliProviderInstance can re-arm the queue-claim
|
|
474
|
+
* agent:ready on the first genuine ready, independent of the boot-time
|
|
475
|
+
* starting→idle one-shot (which is consumed too early for specs whose
|
|
476
|
+
* initial state already reports idle).
|
|
477
|
+
*/
|
|
478
|
+
hasSeenReady(): boolean {
|
|
479
|
+
return this.readySeenOnce;
|
|
480
|
+
}
|
|
469
481
|
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
|
|
470
482
|
// Surface the busy→ready transition's stable countdown, if any, so the
|
|
471
483
|
// existing panel field stays meaningful.
|