@adhdev/daemon-core 0.9.82-rc.195 → 0.9.82-rc.196
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 +1 -0
- package/dist/index.js +202 -70
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +205 -73
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/contracts.d.ts +1 -1
- package/dist/mesh/mesh-active-work.d.ts +1 -1
- package/dist/mesh/{beads-db.d.ts → mesh-runtime-store.d.ts} +2 -2
- package/dist/mesh/mesh-work-queue.d.ts +3 -3
- package/dist/providers/provider-instance.d.ts +1 -1
- package/dist/providers/spec/driver.d.ts +4 -1
- package/dist/providers/spec/schema.gen.d.ts +46 -0
- package/dist/providers/spec/types.d.ts +39 -0
- package/dist/shared-types-extra.d.ts +1 -1
- package/dist/status/normalize.d.ts +1 -1
- package/dist/status/normalize.js +1 -0
- package/dist/status/normalize.js.map +1 -1
- package/dist/status/normalize.mjs +1 -0
- package/dist/status/normalize.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cli-adapter-types.ts +1 -0
- package/src/cli-adapters/cli-state-engine.ts +44 -2
- package/src/mesh/contracts.ts +1 -1
- package/src/mesh/mesh-active-work.ts +8 -8
- package/src/mesh/mesh-events.ts +12 -12
- package/src/mesh/{beads-db.ts → mesh-runtime-store.ts} +30 -7
- package/src/mesh/mesh-work-queue.ts +33 -33
- package/src/providers/cli-provider-instance.ts +31 -8
- package/src/providers/provider-instance.ts +1 -1
- package/src/providers/spec/driver.ts +34 -3
- package/src/providers/spec/evaluator.ts +32 -3
- package/src/providers/spec/schema.gen.ts +22 -2
- package/src/providers/spec/schema.json +1 -0
- package/src/providers/spec/types.ts +39 -0
- package/src/providers/types/interactive-prompt.ts +21 -7
- package/src/shared-types-extra.ts +1 -1
- package/src/status/normalize.ts +2 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, statSync } from 'fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'fs';
|
|
2
2
|
import { dirname, join } from 'path';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import { getLedgerDir } from './mesh-ledger.js';
|
|
@@ -26,8 +26,31 @@ function legacyQueuePath(meshId: string): string {
|
|
|
26
26
|
return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
function meshRuntimeStorePath(): string {
|
|
30
|
+
const dir = getLedgerDir();
|
|
31
|
+
const nextPath = join(dir, 'mesh-runtime.db');
|
|
32
|
+
if (existsSync(nextPath)) return nextPath;
|
|
33
|
+
|
|
34
|
+
const legacyPath = join(dir, 'beads.db');
|
|
35
|
+
if (!existsSync(legacyPath)) return nextPath;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
renameSync(legacyPath, nextPath);
|
|
39
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
40
|
+
const legacyCompanion = `${legacyPath}${suffix}`;
|
|
41
|
+
if (existsSync(legacyCompanion)) {
|
|
42
|
+
renameSync(legacyCompanion, `${nextPath}${suffix}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Best-effort compatibility for existing installs. If migration fails,
|
|
47
|
+
// opening the new store will create a clean DB instead of blocking boot.
|
|
48
|
+
}
|
|
49
|
+
return nextPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class MeshRuntimeStore {
|
|
53
|
+
private static instance: MeshRuntimeStore | undefined;
|
|
31
54
|
private readonly db: DatabaseHandle;
|
|
32
55
|
private readonly dbPath: string;
|
|
33
56
|
private readonly migratedMeshIds = new Set<string>();
|
|
@@ -49,9 +72,9 @@ export class BeadsDB {
|
|
|
49
72
|
this.migrate();
|
|
50
73
|
}
|
|
51
74
|
|
|
52
|
-
static getInstance():
|
|
75
|
+
static getInstance(): MeshRuntimeStore {
|
|
53
76
|
if (!this.instance) {
|
|
54
|
-
this.instance = new
|
|
77
|
+
this.instance = new MeshRuntimeStore(meshRuntimeStorePath());
|
|
55
78
|
}
|
|
56
79
|
return this.instance;
|
|
57
80
|
}
|
|
@@ -149,13 +172,13 @@ export class BeadsDB {
|
|
|
149
172
|
}
|
|
150
173
|
|
|
151
174
|
private maybeCheckpointWal(): void {
|
|
152
|
-
if (++this.walWriteCounter <
|
|
175
|
+
if (++this.walWriteCounter < MeshRuntimeStore.WAL_CHECK_INTERVAL) return;
|
|
153
176
|
this.walWriteCounter = 0;
|
|
154
177
|
try {
|
|
155
178
|
const walPath = `${this.dbPath}-wal`;
|
|
156
179
|
if (!existsSync(walPath)) return;
|
|
157
180
|
const size = statSync(walPath).size;
|
|
158
|
-
if (size <
|
|
181
|
+
if (size < MeshRuntimeStore.WAL_MAX_BYTES) return;
|
|
159
182
|
process.stderr.write(
|
|
160
183
|
`[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
|
|
161
184
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
3
3
|
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
4
|
-
import {
|
|
4
|
+
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
5
5
|
|
|
6
6
|
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
7
7
|
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
@@ -145,15 +145,15 @@ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags:
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
function withQueueLock<T>(_meshId: string, fn: () => T): T {
|
|
148
|
-
return
|
|
148
|
+
return MeshRuntimeStore.getInstance().transaction(fn);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
function readQueue(meshId: string): MeshWorkQueueEntry[] {
|
|
152
|
-
return
|
|
152
|
+
return MeshRuntimeStore.getInstance().getQueueEntries(meshId);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
156
|
-
|
|
156
|
+
MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
@@ -181,7 +181,7 @@ export function enqueueTask(
|
|
|
181
181
|
createdAt: new Date().toISOString(),
|
|
182
182
|
updatedAt: new Date().toISOString(),
|
|
183
183
|
};
|
|
184
|
-
|
|
184
|
+
MeshRuntimeStore.getInstance().insertQueueEntry(entry);
|
|
185
185
|
return entry;
|
|
186
186
|
}
|
|
187
187
|
|
|
@@ -189,18 +189,18 @@ export function enqueueTask(
|
|
|
189
189
|
* Get all tasks in the queue, optionally filtered by status.
|
|
190
190
|
*/
|
|
191
191
|
export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
|
|
192
|
-
return
|
|
192
|
+
return MeshRuntimeStore.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
export function getMeshQueueRevision(meshId: string): string {
|
|
196
|
-
return
|
|
196
|
+
return MeshRuntimeStore.getInstance().getQueueRevision(meshId);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
200
|
* Find the next pending task that this node is allowed to claim, and mark it as assigned.
|
|
201
201
|
*/
|
|
202
202
|
export function claimNextTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[]): MeshWorkQueueEntry | null {
|
|
203
|
-
return
|
|
203
|
+
return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/**
|
|
@@ -215,10 +215,10 @@ export function updateTaskStatus(
|
|
|
215
215
|
): MeshWorkQueueEntry | null {
|
|
216
216
|
requireMeshHostQueueOwner(opts);
|
|
217
217
|
return withQueueLock(meshId, () => {
|
|
218
|
-
const entry =
|
|
218
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
219
219
|
if (!entry) return null;
|
|
220
220
|
entry.status = status;
|
|
221
|
-
|
|
221
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
222
222
|
return entry;
|
|
223
223
|
});
|
|
224
224
|
}
|
|
@@ -229,11 +229,11 @@ export function recordTaskAutoLaunch(
|
|
|
229
229
|
autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
|
|
230
230
|
): MeshWorkQueueEntry | null {
|
|
231
231
|
return withQueueLock(meshId, () => {
|
|
232
|
-
const entry =
|
|
232
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
233
233
|
if (!entry) return null;
|
|
234
234
|
const now = new Date().toISOString();
|
|
235
235
|
entry.autoLaunch = { ...autoLaunch, updatedAt: now };
|
|
236
|
-
|
|
236
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
237
237
|
return entry;
|
|
238
238
|
});
|
|
239
239
|
}
|
|
@@ -248,13 +248,13 @@ export function cancelTask(
|
|
|
248
248
|
): MeshWorkQueueEntry | null {
|
|
249
249
|
requireMeshHostQueueOwner(opts);
|
|
250
250
|
return withQueueLock(meshId, () => {
|
|
251
|
-
const entry =
|
|
251
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
252
252
|
if (!entry) return null;
|
|
253
253
|
const now = new Date().toISOString();
|
|
254
254
|
entry.status = 'cancelled';
|
|
255
255
|
entry.cancelledAt = now;
|
|
256
256
|
if (opts?.reason) entry.cancelReason = opts.reason;
|
|
257
|
-
|
|
257
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
258
258
|
return entry;
|
|
259
259
|
});
|
|
260
260
|
}
|
|
@@ -276,7 +276,7 @@ export function requeueTask(
|
|
|
276
276
|
): MeshWorkQueueEntry | null {
|
|
277
277
|
requireMeshHostQueueOwner(opts);
|
|
278
278
|
return withQueueLock(meshId, () => {
|
|
279
|
-
const entry =
|
|
279
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
280
280
|
if (!entry) return null;
|
|
281
281
|
entry.status = 'pending';
|
|
282
282
|
delete entry.assignedNodeId;
|
|
@@ -290,7 +290,7 @@ export function requeueTask(
|
|
|
290
290
|
entry.requeuedAt = new Date().toISOString();
|
|
291
291
|
entry.requeueCount = (entry.requeueCount || 0) + 1;
|
|
292
292
|
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
293
|
-
|
|
293
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
294
294
|
return entry;
|
|
295
295
|
});
|
|
296
296
|
}
|
|
@@ -306,10 +306,10 @@ export function updateSessionTaskStatus(
|
|
|
306
306
|
): MeshWorkQueueEntry | null {
|
|
307
307
|
return withQueueLock(meshId, () => {
|
|
308
308
|
const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
|
|
309
|
-
const entry =
|
|
309
|
+
const entry = MeshRuntimeStore.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
|
|
310
310
|
if (!entry) return null;
|
|
311
311
|
entry.status = status;
|
|
312
|
-
|
|
312
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
313
313
|
return entry;
|
|
314
314
|
});
|
|
315
315
|
}
|
|
@@ -339,7 +339,7 @@ export interface MeshWorkQueueStats {
|
|
|
339
339
|
* Return aggregate queue statistics for the given mesh.
|
|
340
340
|
*/
|
|
341
341
|
export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
|
|
342
|
-
const rows =
|
|
342
|
+
const rows = MeshRuntimeStore.getInstance().getQueueStatsByStatus(meshId);
|
|
343
343
|
const counts: Record<string, number> = {};
|
|
344
344
|
for (const r of rows) counts[r.status] = r.count;
|
|
345
345
|
const pending = counts['pending'] ?? 0;
|
|
@@ -358,33 +358,33 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
|
|
|
358
358
|
cancelled,
|
|
359
359
|
activeCounts: { pending, assigned },
|
|
360
360
|
historicalCounts: { completed, failed, cancelled },
|
|
361
|
-
activeAssignments:
|
|
361
|
+
activeAssignments: MeshRuntimeStore.getInstance().getActiveAssignmentDetails(meshId),
|
|
362
362
|
};
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
MeshRuntimeStore.getInstance().transaction(() => {
|
|
367
|
+
MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
|
|
368
368
|
});
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
export function __clearMeshQueueForTests(meshId: string): void {
|
|
372
|
-
|
|
372
|
+
MeshRuntimeStore.getInstance().deleteQueue(meshId);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
export function __clearDirectDispatchesForTests(meshId: string): void {
|
|
376
|
-
|
|
376
|
+
MeshRuntimeStore.getInstance().deleteDirectDispatches(meshId);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
export function
|
|
380
|
-
|
|
379
|
+
export function __resetMeshRuntimeStoreForTests(): void {
|
|
380
|
+
MeshRuntimeStore.resetForTests();
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
// ── Direct Dispatch Tracking ─────────────────────────────────────────────────
|
|
384
384
|
// Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
|
|
385
|
-
// active work from
|
|
385
|
+
// active work from MeshRuntimeStore instead of scanning ledger JSONL entries.
|
|
386
386
|
|
|
387
|
-
export type DirectDispatchRecord = ReturnType<
|
|
387
|
+
export type DirectDispatchRecord = ReturnType<MeshRuntimeStore['getActiveDirectDispatches']>[number];
|
|
388
388
|
|
|
389
389
|
export function insertDirectDispatch(
|
|
390
390
|
meshId: string,
|
|
@@ -401,7 +401,7 @@ export function insertDirectDispatch(
|
|
|
401
401
|
},
|
|
402
402
|
): void {
|
|
403
403
|
try {
|
|
404
|
-
|
|
404
|
+
MeshRuntimeStore.getInstance().insertDirectDispatch({ ...data, meshId });
|
|
405
405
|
} catch (e: any) {
|
|
406
406
|
process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
|
|
407
407
|
}
|
|
@@ -409,7 +409,7 @@ export function insertDirectDispatch(
|
|
|
409
409
|
|
|
410
410
|
export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
|
|
411
411
|
try {
|
|
412
|
-
return
|
|
412
|
+
return MeshRuntimeStore.getInstance().getActiveDirectDispatches(meshId);
|
|
413
413
|
} catch {
|
|
414
414
|
return [];
|
|
415
415
|
}
|
|
@@ -421,18 +421,18 @@ export function updateDirectDispatchStatus(
|
|
|
421
421
|
status: 'acked' | 'completed' | 'failed' | 'stale',
|
|
422
422
|
): void {
|
|
423
423
|
try {
|
|
424
|
-
|
|
424
|
+
MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
|
|
425
425
|
} catch { /* best-effort */ }
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
|
|
429
429
|
try {
|
|
430
|
-
|
|
430
|
+
MeshRuntimeStore.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
|
|
431
431
|
} catch { /* best-effort */ }
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
|
|
435
435
|
try {
|
|
436
|
-
|
|
436
|
+
MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
|
|
437
437
|
} catch { /* best-effort */ }
|
|
438
438
|
}
|
|
@@ -724,16 +724,24 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
724
724
|
? visibleStatus
|
|
725
725
|
: (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
|
|
726
726
|
|
|
727
|
+
// If an AskUserQuestion prompt is awaiting user input, overlay status as
|
|
728
|
+
// waiting_choice. This is distinct from waiting_approval (tool-use consent)
|
|
729
|
+
// — the engine's isWaitingForResponse state is unchanged, so completion
|
|
730
|
+
// tracking continues normally once the user responds.
|
|
731
|
+
const hasInteractivePrompt = !!this.activeInteractivePrompt;
|
|
732
|
+
const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
|
|
733
|
+
const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
|
|
734
|
+
|
|
727
735
|
return {
|
|
728
736
|
type: this.type,
|
|
729
737
|
name: this.provider.name,
|
|
730
738
|
category: 'cli',
|
|
731
|
-
status:
|
|
739
|
+
status: finalStatus,
|
|
732
740
|
mode: this.presentationMode,
|
|
733
741
|
activeChat: {
|
|
734
742
|
id: activeChatId,
|
|
735
743
|
title: parsedStatus?.title || dirName,
|
|
736
|
-
status:
|
|
744
|
+
status: finalChatStatus,
|
|
737
745
|
messages: statusMessages,
|
|
738
746
|
activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
|
|
739
747
|
activeInteractivePrompt: this.activeInteractivePrompt,
|
|
@@ -1265,6 +1273,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1265
1273
|
const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
1266
1274
|
const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
|
|
1267
1275
|
const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
|
|
1276
|
+
LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
|
|
1268
1277
|
if (!finalAssistantEvidence.present) {
|
|
1269
1278
|
if (adapterOwnsMessagesElsewhere) {
|
|
1270
1279
|
if (finalAssistantEvidence.source === 'external-native') {
|
|
@@ -1273,19 +1282,20 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1273
1282
|
LOG.info('CLI', `[${this.type}] external transcript probe: msgCount=${probe.msgCount} lastRole=${probe.lastRole || 'none'} lastKind=${probe.lastKind || 'none'} contentLen=${probe.contentLen} sourceMtime=${probe.sourceMtimeMs ?? 'unknown'} mtimeAge=${probe.mtimeAgeMs ?? 'unknown'}ms`);
|
|
1274
1283
|
pending.loggedTranscriptProbe = true;
|
|
1275
1284
|
}
|
|
1285
|
+
LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
|
|
1286
|
+
if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1276
1289
|
if (this.type === 'antigravity-cli') {
|
|
1277
1290
|
return null;
|
|
1278
1291
|
}
|
|
1279
1292
|
return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
|
|
1280
1293
|
}
|
|
1281
|
-
// SpecCliAdapter never populates parsed.messages — chat history flows
|
|
1282
|
-
// through the daemon's native-history pipeline, not the status hook.
|
|
1283
|
-
// If that pipeline is unavailable, keep the old skip behavior for
|
|
1284
|
-
// providers that have not opted into strict final-assistant evidence.
|
|
1285
1294
|
if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
|
|
1286
1295
|
return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
|
|
1287
1296
|
}
|
|
1288
1297
|
} else {
|
|
1298
|
+
LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
|
|
1289
1299
|
return {
|
|
1290
1300
|
reason: 'missing_final_assistant',
|
|
1291
1301
|
terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
|
|
@@ -1331,6 +1341,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1331
1341
|
const latestVisibleStatus = externalNativeFinal && isCliGeneratingLikeStatus(latestStatus.status)
|
|
1332
1342
|
? 'idle'
|
|
1333
1343
|
: (latestAutoApproveActive ? 'generating' : latestStatus.status);
|
|
1344
|
+
LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} externalNativeFinal=${!!externalNativeFinal} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
|
|
1334
1345
|
if (latestVisibleStatus !== 'idle') {
|
|
1335
1346
|
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
|
|
1336
1347
|
this.completedDebouncePending = null;
|
|
@@ -1342,6 +1353,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1342
1353
|
if (block) {
|
|
1343
1354
|
const blockReason = block.reason;
|
|
1344
1355
|
const waitedMs = Date.now() - pending.firstObservedAt;
|
|
1356
|
+
LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
|
|
1345
1357
|
if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
|
|
1346
1358
|
if (pending.loggedBlockReason !== blockReason) {
|
|
1347
1359
|
LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
|
|
@@ -1485,10 +1497,18 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1485
1497
|
if (newStatus !== this.lastStatus) {
|
|
1486
1498
|
LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
|
|
1487
1499
|
if (this.lastStatus === 'idle' && newStatus === 'generating') {
|
|
1500
|
+
// If a completion event is already pending and the turn has ended
|
|
1501
|
+
// (generatingStartedAt===0), the PTY is painting its prompt area
|
|
1502
|
+
// after completing. Ignore this blip — do not cancel the pending
|
|
1503
|
+
// completion and do not advance lastStatus to generating.
|
|
1504
|
+
if (this.completedDebouncePending && this.generatingStartedAt === 0) {
|
|
1505
|
+
LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1488
1508
|
this.suppressIdleHistoryReplay = false;
|
|
1489
1509
|
// Cancel any pending completed event (multi-step: idle→generating resume)
|
|
1490
1510
|
if (this.completedDebouncePending) {
|
|
1491
|
-
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
|
|
1511
|
+
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
|
|
1492
1512
|
if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
|
|
1493
1513
|
this.completedDebouncePending = null;
|
|
1494
1514
|
}
|
|
@@ -1586,7 +1606,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1586
1606
|
firstObservedAt: now,
|
|
1587
1607
|
previousStatus: this.lastStatus,
|
|
1588
1608
|
};
|
|
1589
|
-
this.
|
|
1609
|
+
const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
|
|
1610
|
+
const flushDelay = ownsExternalHistory ? 0 : 3000;
|
|
1611
|
+
LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
|
|
1612
|
+
this.scheduleCompletedDebounceFlush(flushDelay);
|
|
1590
1613
|
}
|
|
1591
1614
|
} else if (newStatus === 'idle' && this.lastStatus === 'starting') {
|
|
1592
1615
|
this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
|
|
@@ -16,7 +16,7 @@ import type { InteractivePrompt } from './types/interactive-prompt.js';
|
|
|
16
16
|
|
|
17
17
|
// ─── ProviderState — Discriminated union by category ─────────────
|
|
18
18
|
|
|
19
|
-
export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'error' | 'stopped' | 'starting';
|
|
19
|
+
export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'waiting_choice' | 'error' | 'stopped' | 'starting';
|
|
20
20
|
|
|
21
21
|
export interface ProviderRuntimeWriteOwner {
|
|
22
22
|
clientId: string;
|
|
@@ -41,6 +41,7 @@ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
|
41
41
|
import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
|
|
42
42
|
import { loadSpec } from './loader.js';
|
|
43
43
|
import type { CliSpec, Control, DelegateTrigger } from './types.js';
|
|
44
|
+
import { LOG } from '../../logging/logger.js';
|
|
44
45
|
|
|
45
46
|
export type DashboardEvent =
|
|
46
47
|
| { kind: 'pty_data'; chunk: string }
|
|
@@ -148,10 +149,32 @@ export function matchesCompletionIdleRule(spec: CliSpec, ev: SpecEvaluation, scr
|
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
export function matchesCompletionIdleTargetState(
|
|
152
|
+
export function matchesCompletionIdleTargetState(
|
|
153
|
+
spec: CliSpec,
|
|
154
|
+
ev: SpecEvaluation,
|
|
155
|
+
screen: string,
|
|
156
|
+
cursor?: { row: number; col: number },
|
|
157
|
+
): boolean {
|
|
152
158
|
const target = spec.states.find(state => state.id === spec.default_state)
|
|
153
159
|
?? spec.states.find(state => state.id === 'idle');
|
|
154
|
-
if (!target?.when
|
|
160
|
+
if (!target?.when) return false;
|
|
161
|
+
|
|
162
|
+
// Cursor-only idle: if the target state has cursor guards but no regex,
|
|
163
|
+
// treat a cursor match alone as sufficient.
|
|
164
|
+
const hasCursorGuard = target.when.cursor_row_min !== undefined
|
|
165
|
+
|| target.when.cursor_row_max !== undefined
|
|
166
|
+
|| target.when.cursor_col_min !== undefined
|
|
167
|
+
|| target.when.cursor_col_max !== undefined;
|
|
168
|
+
if (hasCursorGuard && cursor !== undefined) {
|
|
169
|
+
const { cursor_row_min, cursor_row_max, cursor_col_min, cursor_col_max } = target.when;
|
|
170
|
+
const cursorOk = (cursor_row_min === undefined || cursor.row >= cursor_row_min)
|
|
171
|
+
&& (cursor_row_max === undefined || cursor.row <= cursor_row_max)
|
|
172
|
+
&& (cursor_col_min === undefined || cursor.col >= cursor_col_min)
|
|
173
|
+
&& (cursor_col_max === undefined || cursor.col <= cursor_col_max);
|
|
174
|
+
if (cursorOk) return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!target.when.regex) return false;
|
|
155
178
|
const haystack = target.when.section
|
|
156
179
|
? ev.sections.find(section => section.id === target.when.section)?.text ?? ''
|
|
157
180
|
: screen;
|
|
@@ -314,6 +337,7 @@ export class SpecDriver {
|
|
|
314
337
|
if (this.busyExpiryTimer) clearTimeout(this.busyExpiryTimer);
|
|
315
338
|
this.busyExpiryTimer = setTimeout(() => {
|
|
316
339
|
this.busyExpiryTimer = null;
|
|
340
|
+
LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] busyExpiry fired holdMs=${holdMs}`);
|
|
317
341
|
this.reevaluate();
|
|
318
342
|
}, Math.max(holdMs + 50, 100));
|
|
319
343
|
}
|
|
@@ -352,11 +376,18 @@ export class SpecDriver {
|
|
|
352
376
|
if (completionKey !== this.completionIdleKey) {
|
|
353
377
|
this.completionIdleKey = completionKey;
|
|
354
378
|
this.completionIdleFirstSeenAt = now;
|
|
379
|
+
LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after matched: key="${completionKey}"`);
|
|
355
380
|
}
|
|
356
381
|
const holdMs = Math.max(0, completionIdleRule.hold_ms || 0);
|
|
382
|
+
const forceAfterMs = typeof completionIdleRule.force_after_ms === 'number'
|
|
383
|
+
? completionIdleRule.force_after_ms
|
|
384
|
+
: null;
|
|
357
385
|
const ageMs = now - this.completionIdleFirstSeenAt;
|
|
358
386
|
if (ageMs >= holdMs) {
|
|
359
|
-
|
|
387
|
+
const targetMatches = matchesCompletionIdleTargetState(this.spec, ev, screen, cursor);
|
|
388
|
+
const forced = !targetMatches && forceAfterMs !== null && ageMs >= holdMs + forceAfterMs;
|
|
389
|
+
LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after hold expired ageMs=${ageMs} targetState=${targetMatches} forced=${forced} screenTail="${screen.split(/\r?\n/).slice(-3).join('\\n').slice(-200)}"`);
|
|
390
|
+
if (targetMatches || forced) {
|
|
360
391
|
const idle = this.spec.states.find(state => state.id === this.spec.default_state)
|
|
361
392
|
?? this.spec.states.find(state => state.id === 'idle');
|
|
362
393
|
evState = idle
|
|
@@ -84,10 +84,39 @@ function resolveSections(spec: CliSpec, lines: string[]): ResolvedSection[] {
|
|
|
84
84
|
for (const sec of spec.layout.sections) {
|
|
85
85
|
let from = 0;
|
|
86
86
|
let to = total;
|
|
87
|
-
if (sec.
|
|
87
|
+
if (sec.anchor_regex !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
const re = new RegExp(sec.anchor_regex, sec.anchor_flags ?? '');
|
|
90
|
+
const prevRe = sec.anchor_context?.prev !== undefined
|
|
91
|
+
? new RegExp(sec.anchor_context.prev, sec.anchor_context.prev_flags ?? '') : null;
|
|
92
|
+
const nextRe = sec.anchor_context?.next !== undefined
|
|
93
|
+
? new RegExp(sec.anchor_context.next, sec.anchor_context.next_flags ?? '') : null;
|
|
94
|
+
const matches = (i: number) => re.test(lines[i])
|
|
95
|
+
&& (prevRe === null || (i > 0 && prevRe.test(lines[i - 1])))
|
|
96
|
+
&& (nextRe === null || (i < total - 1 && nextRe.test(lines[i + 1])));
|
|
97
|
+
let idx = -1;
|
|
98
|
+
if (sec.anchor_last) {
|
|
99
|
+
for (let i = total - 1; i >= 0; i--) { if (matches(i)) { idx = i; break; } }
|
|
100
|
+
} else {
|
|
101
|
+
for (let i = 0; i < total; i++) { if (matches(i)) { idx = i; break; } }
|
|
102
|
+
}
|
|
103
|
+
if (idx !== -1) {
|
|
104
|
+
from = idx;
|
|
105
|
+
to = total;
|
|
106
|
+
if (sec.until_regex !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
const ure = new RegExp(sec.until_regex, sec.until_regex_flags ?? '');
|
|
109
|
+
const end = lines.findIndex((l, i) => i > idx && ure.test(l));
|
|
110
|
+
if (end !== -1) to = end;
|
|
111
|
+
} catch { /* bad until_regex — extend to end */ }
|
|
112
|
+
} else if (sec.lines !== undefined) {
|
|
113
|
+
to = Math.min(total, from + sec.lines);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch { /* bad anchor_regex — fall through to defaults */ }
|
|
117
|
+
} else if (sec.from_top !== undefined) {
|
|
88
118
|
from = resolveSize(sec.from_top, total);
|
|
89
|
-
}
|
|
90
|
-
if (sec.from_bottom !== undefined) {
|
|
119
|
+
} else if (sec.from_bottom !== undefined) {
|
|
91
120
|
const sz = resolveSize(sec.from_bottom, total);
|
|
92
121
|
from = total - sz;
|
|
93
122
|
to = total;
|
|
@@ -125,6 +125,9 @@ export const SCHEMA = {
|
|
|
125
125
|
"type": "string",
|
|
126
126
|
"minLength": 1
|
|
127
127
|
},
|
|
128
|
+
"requiresFinalAssistantBeforeIdle": {
|
|
129
|
+
"type": "boolean"
|
|
130
|
+
},
|
|
128
131
|
"debounce": {
|
|
129
132
|
"type": "object",
|
|
130
133
|
"additionalProperties": false,
|
|
@@ -139,7 +142,8 @@ export const SCHEMA = {
|
|
|
139
142
|
"section": { "type": "string", "minLength": 1 },
|
|
140
143
|
"regex": { "type": "string", "minLength": 1 },
|
|
141
144
|
"flags": { "type": "string" },
|
|
142
|
-
"hold_ms": { "type": "integer", "minimum": 0 }
|
|
145
|
+
"hold_ms": { "type": "integer", "minimum": 0 },
|
|
146
|
+
"force_after_ms": { "type": "integer", "minimum": 0 }
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
149
|
}
|
|
@@ -187,7 +191,23 @@ export const SCHEMA = {
|
|
|
187
191
|
"type": "string"
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
|
-
}
|
|
194
|
+
},
|
|
195
|
+
"anchor_regex": { "type": "string", "minLength": 1 },
|
|
196
|
+
"anchor_flags": { "type": "string" },
|
|
197
|
+
"anchor_last": { "type": "boolean" },
|
|
198
|
+
"anchor_context": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"additionalProperties": false,
|
|
201
|
+
"properties": {
|
|
202
|
+
"prev": { "type": "string" },
|
|
203
|
+
"prev_flags": { "type": "string" },
|
|
204
|
+
"next": { "type": "string" },
|
|
205
|
+
"next_flags": { "type": "string" }
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
"lines": { "type": "integer", "minimum": 1 },
|
|
209
|
+
"until_regex": { "type": "string", "minLength": 1 },
|
|
210
|
+
"until_regex_flags": { "type": "string" }
|
|
191
211
|
}
|
|
192
212
|
},
|
|
193
213
|
"sectionRegex": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"native_history": { "$ref": "#/definitions/nativeHistory" },
|
|
63
63
|
"cli_version_range": { "type": "string", "minLength": 1 },
|
|
64
|
+
"requiresFinalAssistantBeforeIdle": { "type": "boolean" },
|
|
64
65
|
"debounce": {
|
|
65
66
|
"type": "object",
|
|
66
67
|
"additionalProperties": false,
|
|
@@ -13,6 +13,33 @@ export interface Section {
|
|
|
13
13
|
from_top?: Size;
|
|
14
14
|
from_bottom?: Size;
|
|
15
15
|
until?: { section: string };
|
|
16
|
+
/** Scan the screen for the first (or last, if anchor_last=true) line
|
|
17
|
+
* matching this regex and use that line as the section start.
|
|
18
|
+
* Combine with `lines`, `until_regex`, or leave alone (defaults to
|
|
19
|
+
* end-of-screen) to control how far it extends. */
|
|
20
|
+
anchor_regex?: string;
|
|
21
|
+
anchor_flags?: string;
|
|
22
|
+
/** If true, use the LAST matching line instead of the first. Useful
|
|
23
|
+
* when the anchor pattern appears multiple times (e.g. separator
|
|
24
|
+
* lines) and you want the one closest to the bottom. */
|
|
25
|
+
anchor_last?: boolean;
|
|
26
|
+
/** Optional neighbouring-line guards that must also match for the
|
|
27
|
+
* anchor to be accepted. Reduces false positives when the anchor
|
|
28
|
+
* pattern could appear elsewhere on screen (e.g. `>` in body text).
|
|
29
|
+
* `prev` checks the line immediately above; `next` the line below. */
|
|
30
|
+
anchor_context?: {
|
|
31
|
+
prev?: string;
|
|
32
|
+
prev_flags?: string;
|
|
33
|
+
next?: string;
|
|
34
|
+
next_flags?: string;
|
|
35
|
+
};
|
|
36
|
+
/** When used with anchor_regex: take at most this many lines from the
|
|
37
|
+
* anchor point. */
|
|
38
|
+
lines?: number;
|
|
39
|
+
/** When used with anchor_regex: extend until the first line matching
|
|
40
|
+
* this regex (exclusive). Takes precedence over `lines`. */
|
|
41
|
+
until_regex?: string;
|
|
42
|
+
until_regex_flags?: string;
|
|
16
43
|
}
|
|
17
44
|
|
|
18
45
|
export interface SectionRegex {
|
|
@@ -224,6 +251,13 @@ export interface CliSpec {
|
|
|
224
251
|
notifications?: NotificationRule[];
|
|
225
252
|
delegate?: DelegateTrigger[];
|
|
226
253
|
native_history?: NativeHistoryConfig;
|
|
254
|
+
/**
|
|
255
|
+
* When true, the daemon's completed-finalization gate will not emit
|
|
256
|
+
* `generating_completed` until native history confirms the last message
|
|
257
|
+
* role is `assistant`. Prevents PTY quiet-period from triggering idle
|
|
258
|
+
* before the CLI has finished writing its response to disk.
|
|
259
|
+
*/
|
|
260
|
+
requiresFinalAssistantBeforeIdle?: boolean;
|
|
227
261
|
/**
|
|
228
262
|
* Per-spec debounce knobs. Defaults are conservative (busy_hold_ms
|
|
229
263
|
* 6000) and live in SpecDriver. Spec authors override here when a
|
|
@@ -251,6 +285,11 @@ export interface CliSpec {
|
|
|
251
285
|
regex: string;
|
|
252
286
|
flags?: string;
|
|
253
287
|
hold_ms: number;
|
|
288
|
+
/** If the target-state check still fails this many ms after hold_ms
|
|
289
|
+
* expired, force an idle transition anyway. Guards against TUIs that
|
|
290
|
+
* show transient UI (pickers, option lists) after the completion
|
|
291
|
+
* marker appears, keeping the target-state regex from matching. */
|
|
292
|
+
force_after_ms?: number;
|
|
254
293
|
};
|
|
255
294
|
};
|
|
256
295
|
}
|