@adhdev/daemon-core 0.9.82-rc.196 → 0.9.82-rc.198
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/index.d.ts +2 -0
- package/dist/index.js +507 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +505 -26
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-delivery-policy.d.ts +126 -0
- package/dist/mesh/mesh-runtime-store.d.ts +66 -0
- package/dist/providers/spec/driver.d.ts +19 -0
- package/dist/providers/spec/types.d.ts +6 -0
- package/package.json +1 -1
- package/src/commands/router.ts +19 -0
- package/src/index.ts +4 -0
- package/src/mesh/mesh-delivery-policy.ts +298 -0
- package/src/mesh/mesh-events.ts +52 -3
- package/src/mesh/mesh-runtime-store.ts +219 -0
- package/src/providers/spec/cli-adapter.ts +4 -0
- package/src/providers/spec/driver.ts +85 -3
- package/src/providers/spec/types.ts +6 -0
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
|
|
|
10
10
|
import { buildMeshNodeCapabilityTags, claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches } from './mesh-work-queue.js';
|
|
11
11
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
12
12
|
import { fastForwardMeshNode } from './mesh-fast-forward.js';
|
|
13
|
+
import { createSessionDelivery, updateSessionDeliveryStatus, recordCompletionConflict } from './mesh-delivery-policy.js';
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Remote Node Idle Session Tracking
|
|
@@ -499,10 +500,27 @@ function isDuplicateMeshCompletionEvent(args: {
|
|
|
499
500
|
timestamp?: number | null;
|
|
500
501
|
finalSummary?: string;
|
|
501
502
|
coordinatorDaemonId?: string;
|
|
503
|
+
taskId?: string;
|
|
504
|
+
nodeId?: string;
|
|
502
505
|
}): boolean {
|
|
503
506
|
const fingerprint = buildMeshCompletionFingerprint(args);
|
|
504
507
|
if (!fingerprint) return false;
|
|
505
|
-
if (hasFingerprintSeen(fingerprint))
|
|
508
|
+
if (hasFingerprintSeen(fingerprint)) {
|
|
509
|
+
// Suppressed duplicate — but if we have a taskId and it differs from what the
|
|
510
|
+
// fingerprint was stamped for, record a conflict diagnostic so it doesn't disappear silently.
|
|
511
|
+
// (We can't recover the original taskId from the fingerprint alone, so we record
|
|
512
|
+
// the conflicting taskId/session as a diagnostic for coordinator inspection.)
|
|
513
|
+
if (args.taskId) {
|
|
514
|
+
recordCompletionConflict({
|
|
515
|
+
meshId: args.meshId,
|
|
516
|
+
fingerprint,
|
|
517
|
+
conflictingTaskId: args.taskId,
|
|
518
|
+
conflictingSessionId: args.sessionId,
|
|
519
|
+
event: args.event,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
506
524
|
recordFingerprintSeen(fingerprint);
|
|
507
525
|
return false;
|
|
508
526
|
}
|
|
@@ -837,13 +855,27 @@ export function tryAssignQueueTask(
|
|
|
837
855
|
if (node?.daemonId && components.dispatchMeshCommand) {
|
|
838
856
|
const isLocalNode = components.cliManager.adapters.has(sessionId);
|
|
839
857
|
if (!isLocalNode) {
|
|
858
|
+
// Create delivery record before attempting P2P send
|
|
859
|
+
const delivery = createSessionDelivery({
|
|
860
|
+
meshId,
|
|
861
|
+
nodeId,
|
|
862
|
+
sessionId,
|
|
863
|
+
providerType,
|
|
864
|
+
taskId: task.id,
|
|
865
|
+
kind: 'task',
|
|
866
|
+
message: task.message,
|
|
867
|
+
status: 'delivering',
|
|
868
|
+
});
|
|
840
869
|
components.dispatchMeshCommand(node.daemonId, 'agent_command', {
|
|
841
870
|
targetSessionId: sessionId,
|
|
842
871
|
cliType: providerType,
|
|
843
872
|
action: 'send_chat',
|
|
844
873
|
message: task.message,
|
|
874
|
+
}).then(() => {
|
|
875
|
+
updateSessionDeliveryStatus(delivery.id, 'delivered');
|
|
845
876
|
}).catch((e: any) => {
|
|
846
877
|
LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
|
|
878
|
+
updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
|
|
847
879
|
// Revert to pending so the task can be retried rather than permanently failing
|
|
848
880
|
updateTaskStatus(meshId, task.id, 'pending');
|
|
849
881
|
try {
|
|
@@ -851,7 +883,7 @@ export function tryAssignQueueTask(
|
|
|
851
883
|
kind: 'dispatch_failed' as any,
|
|
852
884
|
nodeId,
|
|
853
885
|
sessionId,
|
|
854
|
-
payload: { taskId: task.id, error: e?.message, retryable: true },
|
|
886
|
+
payload: { taskId: task.id, deliveryId: delivery.id, error: e?.message, retryable: true },
|
|
855
887
|
});
|
|
856
888
|
} catch { /* ledger write is best-effort */ }
|
|
857
889
|
});
|
|
@@ -859,14 +891,27 @@ export function tryAssignQueueTask(
|
|
|
859
891
|
}
|
|
860
892
|
}
|
|
861
893
|
|
|
862
|
-
// Local routing
|
|
894
|
+
// Local routing — create delivery record before send_chat
|
|
895
|
+
const delivery = createSessionDelivery({
|
|
896
|
+
meshId,
|
|
897
|
+
nodeId,
|
|
898
|
+
sessionId,
|
|
899
|
+
providerType,
|
|
900
|
+
taskId: task.id,
|
|
901
|
+
kind: 'task',
|
|
902
|
+
message: task.message,
|
|
903
|
+
status: 'delivering',
|
|
904
|
+
});
|
|
863
905
|
components.cliManager.handleCliCommand('agent_command', {
|
|
864
906
|
targetSessionId: sessionId,
|
|
865
907
|
cliType: providerType,
|
|
866
908
|
action: 'send_chat',
|
|
867
909
|
message: task.message,
|
|
910
|
+
}).then(() => {
|
|
911
|
+
updateSessionDeliveryStatus(delivery.id, 'delivered');
|
|
868
912
|
}).catch((e: any) => {
|
|
869
913
|
LOG.error('MeshQueue', `Failed to dispatch task locally to node ${nodeId}: ${e?.message}`);
|
|
914
|
+
updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
|
|
870
915
|
updateTaskStatus(meshId, task.id, 'failed');
|
|
871
916
|
});
|
|
872
917
|
|
|
@@ -1625,6 +1670,8 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1625
1670
|
// Scope dedup to the coordinator daemon so two coordinators for the same mesh
|
|
1626
1671
|
// don't suppress each other's completion events via shared fingerprint table.
|
|
1627
1672
|
coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
|
|
1673
|
+
taskId: readNonEmptyString(args.metadataEvent.taskId) || undefined,
|
|
1674
|
+
nodeId: eventNodeId || undefined,
|
|
1628
1675
|
});
|
|
1629
1676
|
if (duplicateCompletion) {
|
|
1630
1677
|
LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
|
|
@@ -1641,6 +1688,8 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1641
1688
|
timestamp: eventTimestamp,
|
|
1642
1689
|
finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
|
|
1643
1690
|
coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
|
|
1691
|
+
taskId: readNonEmptyString(args.metadataEvent.taskId) || undefined,
|
|
1692
|
+
nodeId: eventNodeId || undefined,
|
|
1644
1693
|
});
|
|
1645
1694
|
if (duplicateStopped) {
|
|
1646
1695
|
LOG.info('MeshEvents', `Suppressed duplicate stopped event for mesh ${args.meshId} session ${eventSessionId}`);
|
|
@@ -143,6 +143,49 @@ export class MeshRuntimeStore {
|
|
|
143
143
|
metadata TEXT,
|
|
144
144
|
PRIMARY KEY (node_id, session_id)
|
|
145
145
|
);
|
|
146
|
+
|
|
147
|
+
CREATE TABLE IF NOT EXISTS mesh_session_delivery (
|
|
148
|
+
id TEXT PRIMARY KEY,
|
|
149
|
+
mesh_id TEXT NOT NULL,
|
|
150
|
+
node_id TEXT,
|
|
151
|
+
session_id TEXT,
|
|
152
|
+
provider_type TEXT,
|
|
153
|
+
task_id TEXT,
|
|
154
|
+
kind TEXT NOT NULL,
|
|
155
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
156
|
+
message TEXT NOT NULL,
|
|
157
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
158
|
+
deliver_after TEXT,
|
|
159
|
+
expires_at TEXT,
|
|
160
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
161
|
+
source_coordinator_session_id TEXT,
|
|
162
|
+
source_coordinator_daemon_id TEXT,
|
|
163
|
+
last_error TEXT,
|
|
164
|
+
created_at TEXT NOT NULL,
|
|
165
|
+
updated_at TEXT NOT NULL
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_mesh_status
|
|
169
|
+
ON mesh_session_delivery(mesh_id, status, created_at);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_session
|
|
171
|
+
ON mesh_session_delivery(mesh_id, session_id, status);
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_task
|
|
173
|
+
ON mesh_session_delivery(mesh_id, task_id);
|
|
174
|
+
|
|
175
|
+
CREATE TABLE IF NOT EXISTS mesh_completion_conflicts (
|
|
176
|
+
id TEXT PRIMARY KEY,
|
|
177
|
+
mesh_id TEXT NOT NULL,
|
|
178
|
+
fingerprint TEXT NOT NULL,
|
|
179
|
+
conflicting_task_id TEXT,
|
|
180
|
+
conflicting_session_id TEXT,
|
|
181
|
+
original_task_id TEXT,
|
|
182
|
+
original_session_id TEXT,
|
|
183
|
+
event TEXT NOT NULL,
|
|
184
|
+
created_at TEXT NOT NULL
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
CREATE INDEX IF NOT EXISTS idx_mesh_completion_conflicts_mesh
|
|
188
|
+
ON mesh_completion_conflicts(mesh_id, created_at);
|
|
146
189
|
`);
|
|
147
190
|
}
|
|
148
191
|
|
|
@@ -547,4 +590,180 @@ export class MeshRuntimeStore {
|
|
|
547
590
|
pruneExpiredRemoteIdleSessions(): void {
|
|
548
591
|
this.db.prepare('DELETE FROM remote_idle_sessions WHERE expires_at <= ?').run(Date.now());
|
|
549
592
|
}
|
|
593
|
+
|
|
594
|
+
// ── Session Delivery Queue ───────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
insertSessionDelivery(entry: {
|
|
597
|
+
id: string;
|
|
598
|
+
meshId: string;
|
|
599
|
+
nodeId?: string;
|
|
600
|
+
sessionId?: string;
|
|
601
|
+
providerType?: string;
|
|
602
|
+
taskId?: string;
|
|
603
|
+
kind: string;
|
|
604
|
+
priority?: number;
|
|
605
|
+
message: string;
|
|
606
|
+
status: string;
|
|
607
|
+
deliverAfter?: string;
|
|
608
|
+
expiresAt?: string;
|
|
609
|
+
sourceCoordinatorSessionId?: string;
|
|
610
|
+
sourceCoordinatorDaemonId?: string;
|
|
611
|
+
createdAt: string;
|
|
612
|
+
updatedAt: string;
|
|
613
|
+
}): void {
|
|
614
|
+
this.db.prepare(`
|
|
615
|
+
INSERT OR REPLACE INTO mesh_session_delivery (
|
|
616
|
+
id, mesh_id, node_id, session_id, provider_type, task_id, kind, priority,
|
|
617
|
+
message, status, deliver_after, expires_at, attempt_count,
|
|
618
|
+
source_coordinator_session_id, source_coordinator_daemon_id,
|
|
619
|
+
last_error, created_at, updated_at
|
|
620
|
+
) VALUES (
|
|
621
|
+
@id, @meshId, @nodeId, @sessionId, @providerType, @taskId, @kind, @priority,
|
|
622
|
+
@message, @status, @deliverAfter, @expiresAt, 0,
|
|
623
|
+
@sourceCoordinatorSessionId, @sourceCoordinatorDaemonId,
|
|
624
|
+
NULL, @createdAt, @updatedAt
|
|
625
|
+
)
|
|
626
|
+
`).run({
|
|
627
|
+
id: entry.id,
|
|
628
|
+
meshId: entry.meshId,
|
|
629
|
+
nodeId: entry.nodeId ?? null,
|
|
630
|
+
sessionId: entry.sessionId ?? null,
|
|
631
|
+
providerType: entry.providerType ?? null,
|
|
632
|
+
taskId: entry.taskId ?? null,
|
|
633
|
+
kind: entry.kind,
|
|
634
|
+
priority: entry.priority ?? 0,
|
|
635
|
+
message: entry.message,
|
|
636
|
+
status: entry.status,
|
|
637
|
+
deliverAfter: entry.deliverAfter ?? null,
|
|
638
|
+
expiresAt: entry.expiresAt ?? null,
|
|
639
|
+
sourceCoordinatorSessionId: entry.sourceCoordinatorSessionId ?? null,
|
|
640
|
+
sourceCoordinatorDaemonId: entry.sourceCoordinatorDaemonId ?? null,
|
|
641
|
+
createdAt: entry.createdAt,
|
|
642
|
+
updatedAt: entry.updatedAt,
|
|
643
|
+
});
|
|
644
|
+
this.maybeCheckpointWal();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
updateSessionDeliveryStatus(id: string, status: string, opts?: { lastError?: string; incrementAttempt?: boolean }): void {
|
|
648
|
+
const now = new Date().toISOString();
|
|
649
|
+
if (opts?.incrementAttempt) {
|
|
650
|
+
this.db.prepare(`
|
|
651
|
+
UPDATE mesh_session_delivery
|
|
652
|
+
SET status = @status, last_error = @lastError, attempt_count = attempt_count + 1, updated_at = @updatedAt
|
|
653
|
+
WHERE id = @id
|
|
654
|
+
`).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
|
|
655
|
+
} else {
|
|
656
|
+
this.db.prepare(`
|
|
657
|
+
UPDATE mesh_session_delivery
|
|
658
|
+
SET status = @status, last_error = @lastError, updated_at = @updatedAt
|
|
659
|
+
WHERE id = @id
|
|
660
|
+
`).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
|
|
665
|
+
id: string; meshId: string; nodeId: string | null; sessionId: string | null;
|
|
666
|
+
providerType: string | null; taskId: string | null; kind: string; priority: number;
|
|
667
|
+
message: string; status: string; deliverAfter: string | null; expiresAt: string | null;
|
|
668
|
+
attemptCount: number; sourceCoordinatorSessionId: string | null;
|
|
669
|
+
sourceCoordinatorDaemonId: string | null; lastError: string | null;
|
|
670
|
+
createdAt: string; updatedAt: string;
|
|
671
|
+
}> {
|
|
672
|
+
const now = new Date().toISOString();
|
|
673
|
+
const sql = sessionId
|
|
674
|
+
? `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND session_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`
|
|
675
|
+
: `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`;
|
|
676
|
+
const rows = sessionId
|
|
677
|
+
? this.db.prepare(sql).all(meshId, sessionId, now) as Array<Record<string, unknown>>
|
|
678
|
+
: this.db.prepare(sql).all(meshId, now) as Array<Record<string, unknown>>;
|
|
679
|
+
return rows.map(r => ({
|
|
680
|
+
id: r.id as string,
|
|
681
|
+
meshId: r.mesh_id as string,
|
|
682
|
+
nodeId: r.node_id as string | null,
|
|
683
|
+
sessionId: r.session_id as string | null,
|
|
684
|
+
providerType: r.provider_type as string | null,
|
|
685
|
+
taskId: r.task_id as string | null,
|
|
686
|
+
kind: r.kind as string,
|
|
687
|
+
priority: r.priority as number,
|
|
688
|
+
message: r.message as string,
|
|
689
|
+
status: r.status as string,
|
|
690
|
+
deliverAfter: r.deliver_after as string | null,
|
|
691
|
+
expiresAt: r.expires_at as string | null,
|
|
692
|
+
attemptCount: r.attempt_count as number,
|
|
693
|
+
sourceCoordinatorSessionId: r.source_coordinator_session_id as string | null,
|
|
694
|
+
sourceCoordinatorDaemonId: r.source_coordinator_daemon_id as string | null,
|
|
695
|
+
lastError: r.last_error as string | null,
|
|
696
|
+
createdAt: r.created_at as string,
|
|
697
|
+
updatedAt: r.updated_at as string,
|
|
698
|
+
}));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
expireStaleSessionDeliveries(meshId: string): void {
|
|
702
|
+
const now = new Date().toISOString();
|
|
703
|
+
this.db.prepare(`
|
|
704
|
+
UPDATE mesh_session_delivery
|
|
705
|
+
SET status = 'expired', updated_at = ?
|
|
706
|
+
WHERE mesh_id = ? AND expires_at IS NOT NULL AND expires_at <= ?
|
|
707
|
+
AND status NOT IN ('delivered','completed','failed','expired','cancelled')
|
|
708
|
+
`).run(now, meshId, now);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
deleteSessionDeliveries(meshId: string): void {
|
|
712
|
+
this.db.prepare('DELETE FROM mesh_session_delivery WHERE mesh_id = ?').run(meshId);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── Completion Conflict Diagnostics ──────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
recordCompletionConflict(entry: {
|
|
718
|
+
id: string;
|
|
719
|
+
meshId: string;
|
|
720
|
+
fingerprint: string;
|
|
721
|
+
conflictingTaskId?: string;
|
|
722
|
+
conflictingSessionId?: string;
|
|
723
|
+
originalTaskId?: string;
|
|
724
|
+
originalSessionId?: string;
|
|
725
|
+
event: string;
|
|
726
|
+
createdAt: string;
|
|
727
|
+
}): void {
|
|
728
|
+
this.db.prepare(`
|
|
729
|
+
INSERT OR IGNORE INTO mesh_completion_conflicts
|
|
730
|
+
(id, mesh_id, fingerprint, conflicting_task_id, conflicting_session_id,
|
|
731
|
+
original_task_id, original_session_id, event, created_at)
|
|
732
|
+
VALUES (@id, @meshId, @fingerprint, @conflictingTaskId, @conflictingSessionId,
|
|
733
|
+
@originalTaskId, @originalSessionId, @event, @createdAt)
|
|
734
|
+
`).run({
|
|
735
|
+
id: entry.id,
|
|
736
|
+
meshId: entry.meshId,
|
|
737
|
+
fingerprint: entry.fingerprint,
|
|
738
|
+
conflictingTaskId: entry.conflictingTaskId ?? null,
|
|
739
|
+
conflictingSessionId: entry.conflictingSessionId ?? null,
|
|
740
|
+
originalTaskId: entry.originalTaskId ?? null,
|
|
741
|
+
originalSessionId: entry.originalSessionId ?? null,
|
|
742
|
+
event: entry.event,
|
|
743
|
+
createdAt: entry.createdAt,
|
|
744
|
+
});
|
|
745
|
+
this.maybeCheckpointWal();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
getRecentCompletionConflicts(meshId: string, limitMs: number = 60 * 60 * 1000): Array<{
|
|
749
|
+
id: string; meshId: string; fingerprint: string; conflictingTaskId: string | null;
|
|
750
|
+
conflictingSessionId: string | null; originalTaskId: string | null;
|
|
751
|
+
originalSessionId: string | null; event: string; createdAt: string;
|
|
752
|
+
}> {
|
|
753
|
+
const cutoff = new Date(Date.now() - limitMs).toISOString();
|
|
754
|
+
const rows = this.db.prepare(
|
|
755
|
+
'SELECT * FROM mesh_completion_conflicts WHERE mesh_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 50'
|
|
756
|
+
).all(meshId, cutoff) as Array<Record<string, unknown>>;
|
|
757
|
+
return rows.map(r => ({
|
|
758
|
+
id: r.id as string,
|
|
759
|
+
meshId: r.mesh_id as string,
|
|
760
|
+
fingerprint: r.fingerprint as string,
|
|
761
|
+
conflictingTaskId: r.conflicting_task_id as string | null,
|
|
762
|
+
conflictingSessionId: r.conflicting_session_id as string | null,
|
|
763
|
+
originalTaskId: r.original_task_id as string | null,
|
|
764
|
+
originalSessionId: r.original_session_id as string | null,
|
|
765
|
+
event: r.event as string,
|
|
766
|
+
createdAt: r.created_at as string,
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
550
769
|
}
|
|
@@ -349,6 +349,10 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
349
349
|
exited: this.exited,
|
|
350
350
|
screen,
|
|
351
351
|
sections,
|
|
352
|
+
stateHistory: this.driver.getStateHistory(),
|
|
353
|
+
idleHoldPending: this.driver.hasIdleHoldPending(),
|
|
354
|
+
lastBusyAt: this.driver.getLastBusyAt(),
|
|
355
|
+
specPath: this.driver.getSpecPath(),
|
|
352
356
|
};
|
|
353
357
|
}
|
|
354
358
|
getRuntimeMetadata(): unknown {
|
|
@@ -218,7 +218,15 @@ export class SpecDriver {
|
|
|
218
218
|
* explicit wake-up there's nothing to trigger the busy → idle
|
|
219
219
|
* downshift. */
|
|
220
220
|
private busyExpiryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
|
+
/** Pending idle-commit timer. Armed when the evaluator first returns idle;
|
|
222
|
+
* fires after idle_hold_ms if no non-idle reading has cancelled it. */
|
|
223
|
+
private idleHoldTimer: ReturnType<typeof setTimeout> | null = null;
|
|
224
|
+
/** State snapshot captured when the idle hold was armed — emitted on commit. */
|
|
225
|
+
private pendingIdleState: SpecEvaluation['state'] | null = null;
|
|
221
226
|
private specWatcher: fs.FSWatcher | null = null;
|
|
227
|
+
/** Ring buffer of committed state transitions (max 50). */
|
|
228
|
+
private stateHistory: Array<{ stateId: string; label: string; at: number; durationMs: number }> = [];
|
|
229
|
+
private prevStateAt = 0;
|
|
222
230
|
|
|
223
231
|
constructor(private readonly opts: SpecDriverOpts) {
|
|
224
232
|
this.loadSpecOrThrow();
|
|
@@ -277,10 +285,33 @@ export class SpecDriver {
|
|
|
277
285
|
shutdown(): void {
|
|
278
286
|
for (const t of this.delegateTimers.values()) clearTimeout(t);
|
|
279
287
|
this.delegateTimers.clear();
|
|
288
|
+
this.cancelIdleHold();
|
|
289
|
+
if (this.busyExpiryTimer) { clearTimeout(this.busyExpiryTimer); this.busyExpiryTimer = null; }
|
|
280
290
|
this.specWatcher?.close();
|
|
281
291
|
this.adapter.kill();
|
|
282
292
|
}
|
|
283
293
|
|
|
294
|
+
private cancelIdleHold(): void {
|
|
295
|
+
if (this.idleHoldTimer) { clearTimeout(this.idleHoldTimer); this.idleHoldTimer = null; }
|
|
296
|
+
this.pendingIdleState = null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private pushHistory(stateId: string, label: string): void {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
|
|
302
|
+
this.prevStateAt = now;
|
|
303
|
+
this.stateHistory.push({ stateId, label, at: now, durationMs });
|
|
304
|
+
if (this.stateHistory.length > 50) this.stateHistory.shift();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getStateHistory(): ReadonlyArray<{ stateId: string; label: string; at: number; durationMs: number }> {
|
|
308
|
+
return this.stateHistory;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getLastBusyAt(): number { return this.lastBusyAt; }
|
|
312
|
+
hasIdleHoldPending(): boolean { return this.idleHoldTimer !== null; }
|
|
313
|
+
getSpecPath(): string { return this.opts.specPath; }
|
|
314
|
+
|
|
284
315
|
// ────────────────────────────────────────────────────────────────────
|
|
285
316
|
// Loading & adapter wiring
|
|
286
317
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -307,9 +338,13 @@ export class SpecDriver {
|
|
|
307
338
|
|
|
308
339
|
private armSpecWatcher(): void {
|
|
309
340
|
try {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
341
|
+
// Watch the parent directory so we catch atomic replacements
|
|
342
|
+
// (cp, install scripts) that create a new inode — a file-level
|
|
343
|
+
// watch misses those on macOS because the original inode is gone.
|
|
344
|
+
const dir = path.dirname(this.opts.specPath);
|
|
345
|
+
const base = path.basename(this.opts.specPath);
|
|
346
|
+
this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
|
|
347
|
+
if (filename && filename !== base) return;
|
|
313
348
|
const res = loadSpec(this.opts.specPath);
|
|
314
349
|
if (!res.ok) { this.emit({ kind: 'spec_error', errors: res.errors }); return; }
|
|
315
350
|
this.spec = res.spec;
|
|
@@ -411,12 +446,58 @@ export class SpecDriver {
|
|
|
411
446
|
if (evState.id === 'busy') {
|
|
412
447
|
this.lastBusyAt = Date.now();
|
|
413
448
|
this.lastBusyState = evState;
|
|
449
|
+
// Cancel any pending idle commit — non-idle reading invalidates it.
|
|
450
|
+
this.cancelIdleHold();
|
|
414
451
|
// Schedule a re-evaluation when the hold window expires. PTYs
|
|
415
452
|
// typically stop emitting once the agent stops printing (the
|
|
416
453
|
// footer settles), so without an explicit timer the driver
|
|
417
454
|
// never wakes up to downshift to idle and the dashboard sees
|
|
418
455
|
// status stuck at generating long after the turn ended.
|
|
419
456
|
this.scheduleBusyExpiry(busyWakeMs);
|
|
457
|
+
} else if (evState.id !== this.currentStateId && evState.id !== 'busy') {
|
|
458
|
+
// Non-busy modal states (approval, picker, signing_in) also cancel
|
|
459
|
+
// any in-flight idle hold — they are higher-priority than idle.
|
|
460
|
+
if (evState.id !== (this.spec.default_state ?? 'idle')) {
|
|
461
|
+
this.cancelIdleHold();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Idle hold: if idle_hold_ms is set, don't commit idle immediately.
|
|
466
|
+
// Arm a timer; if a non-idle reading arrives before it fires, cancel.
|
|
467
|
+
const idleHoldMs = this.spec.debounce?.idle_hold_ms ?? 0;
|
|
468
|
+
const isIdleState = evState.id === (this.spec.default_state ?? 'idle');
|
|
469
|
+
if (isIdleState && idleHoldMs > 0 && this.currentStateId !== evState.id) {
|
|
470
|
+
if (!this.idleHoldTimer) {
|
|
471
|
+
this.pendingIdleState = evState;
|
|
472
|
+
this.idleHoldTimer = setTimeout(() => {
|
|
473
|
+
this.idleHoldTimer = null;
|
|
474
|
+
const committed = this.pendingIdleState;
|
|
475
|
+
this.pendingIdleState = null;
|
|
476
|
+
if (!committed) return;
|
|
477
|
+
LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] idleHold committed after ${idleHoldMs}ms`);
|
|
478
|
+
this.currentStateId = committed.id;
|
|
479
|
+
this.currentEval = ev;
|
|
480
|
+
this.pushHistory(committed.id, committed.label);
|
|
481
|
+
this.emit({
|
|
482
|
+
kind: 'state_changed',
|
|
483
|
+
state: committed,
|
|
484
|
+
modal: null,
|
|
485
|
+
controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
|
|
486
|
+
});
|
|
487
|
+
this.armOrCancelDelegateTimers(committed.id);
|
|
488
|
+
if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
|
|
489
|
+
}, idleHoldMs);
|
|
490
|
+
}
|
|
491
|
+
// Don't fall through to the normal changed/emit path for idle.
|
|
492
|
+
this.currentEval = ev;
|
|
493
|
+
const graceMs2 = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
|
|
494
|
+
if (!this.idleSeenOnce && Date.now() - this.startedAtMs >= graceMs2) {
|
|
495
|
+
this.idleSeenOnce = true;
|
|
496
|
+
const queued = this.pendingSends.splice(0);
|
|
497
|
+
for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
|
|
498
|
+
}
|
|
499
|
+
if (this.pickerInProgress) this.tryAdvancePicker(screen);
|
|
500
|
+
return;
|
|
420
501
|
}
|
|
421
502
|
|
|
422
503
|
const changed = forceEmit
|
|
@@ -449,6 +530,7 @@ export class SpecDriver {
|
|
|
449
530
|
}
|
|
450
531
|
if (changed) {
|
|
451
532
|
this.currentStateId = evState.id;
|
|
533
|
+
this.pushHistory(evState.id, evState.label);
|
|
452
534
|
this.emit({
|
|
453
535
|
kind: 'state_changed',
|
|
454
536
|
state: evState,
|
|
@@ -268,6 +268,12 @@ export interface CliSpec {
|
|
|
268
268
|
* Absorbs per-frame flicker in TUIs that stream output through
|
|
269
269
|
* the same region as the spinner. */
|
|
270
270
|
busy_hold_ms?: number;
|
|
271
|
+
/** Min time the idle state must remain matched before it is
|
|
272
|
+
* committed. Filters transient idle flickers that appear during
|
|
273
|
+
* approval dismissals, layout reflows, or brief spinner gaps.
|
|
274
|
+
* Any non-idle reading within the window cancels the transition.
|
|
275
|
+
* When omitted the idle transition is immediate (legacy behaviour). */
|
|
276
|
+
idle_hold_ms?: number;
|
|
271
277
|
/** Min time after start() before a send_message is allowed to
|
|
272
278
|
* reach the PTY. Banner paints + auth flows + skill listings
|
|
273
279
|
* can keep the agent unable to accept input for several seconds
|