@adhdev/daemon-core 1.0.18-rc.10 → 1.0.18-rc.11
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.js +690 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +690 -35
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-node-identity.d.ts +23 -0
- package/dist/mesh/mesh-refine-gates.d.ts +89 -0
- package/dist/mesh/mesh-work-queue.d.ts +24 -1
- package/dist/providers/cli-provider-instance-types.d.ts +2 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
- package/dist/providers/types/interactive-prompt.d.ts +18 -0
- package/package.json +3 -3
- package/src/commands/high-family/mesh-events.ts +13 -2
- package/src/commands/med-family/mesh-queue.ts +74 -1
- package/src/commands/router-refine.ts +71 -4
- package/src/commands/router.ts +8 -0
- package/src/mesh/coordinator-prompt.ts +2 -1
- package/src/mesh/mesh-active-work.ts +11 -1
- package/src/mesh/mesh-event-classify.ts +19 -0
- package/src/mesh/mesh-event-forwarding.ts +46 -4
- package/src/mesh/mesh-events-utils.ts +42 -0
- package/src/mesh/mesh-ledger.ts +5 -0
- package/src/mesh/mesh-node-identity.ts +49 -0
- package/src/mesh/mesh-queue-assignment.ts +18 -1
- package/src/mesh/mesh-reconcile-loop.ts +6 -1
- package/src/mesh/mesh-refine-gates.ts +300 -0
- package/src/mesh/mesh-work-queue.ts +85 -2
- package/src/providers/cli-provider-instance-types.ts +28 -0
- package/src/providers/cli-provider-instance.ts +90 -15
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
- package/src/providers/types/interactive-prompt.ts +77 -0
|
@@ -1231,6 +1231,19 @@ function propagateDependencyFailure(meshId: string, failedTaskId: string): MeshW
|
|
|
1231
1231
|
|
|
1232
1232
|
const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
|
|
1233
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* CANCEL-STICKY-TERMINAL: the terminal task statuses. A row in one of these states is a
|
|
1236
|
+
* historical record — no live dispatch owns it — and must NEVER be flipped back to an
|
|
1237
|
+
* active (`pending`/`assigned`) state by a late writer. The canonical live example is a
|
|
1238
|
+
* cancel that races the dispatch-failure `.catch` (mesh-queue-assignment.ts): that catch
|
|
1239
|
+
* fires-and-forgets an unconditional `updateTaskStatus(...,'pending')`, resolving AFTER
|
|
1240
|
+
* the cancel commits, which resurrected the cancelled row → it got re-claimed and the
|
|
1241
|
+
* reclaim watchdog re-drove the same prompt. Guarding the write side (see
|
|
1242
|
+
* {@link updateTaskStatus}) applies the same terminal-row protection
|
|
1243
|
+
* {@link reclaimStrandedAssignedTask} already enforces to EVERY status writer at once.
|
|
1244
|
+
*/
|
|
1245
|
+
const TERMINAL_TASK_STATUSES = new Set<MeshTaskStatus>(['completed', 'failed', 'cancelled']);
|
|
1246
|
+
|
|
1234
1247
|
/**
|
|
1235
1248
|
* G3 (step ①) — fire-and-forget mission_close_candidate detection for the missions of
|
|
1236
1249
|
* the given task ids. Called after any task-status mutation (completion / failure /
|
|
@@ -1267,12 +1280,33 @@ export function updateTaskStatus(
|
|
|
1267
1280
|
meshId: string,
|
|
1268
1281
|
taskId: string,
|
|
1269
1282
|
status: MeshTaskStatus,
|
|
1270
|
-
opts?:
|
|
1283
|
+
opts?: {
|
|
1284
|
+
/**
|
|
1285
|
+
* CANCEL-STICKY-TERMINAL: operator/system override to permit a terminal→non-terminal
|
|
1286
|
+
* transition (e.g. an explicit operator reopen). Without this, a write that would flip
|
|
1287
|
+
* a `completed`/`failed`/`cancelled` row back to `pending`/`assigned` is refused as a
|
|
1288
|
+
* no-op. Terminal→terminal and any transition FROM a non-terminal state are unaffected.
|
|
1289
|
+
*/
|
|
1290
|
+
force?: boolean;
|
|
1291
|
+
} & MeshQueueMutationOptions,
|
|
1271
1292
|
): MeshWorkQueueEntry | null {
|
|
1272
1293
|
requireMeshHostQueueOwner(opts);
|
|
1273
1294
|
const result = withQueueLock(meshId, () => {
|
|
1274
1295
|
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1275
1296
|
if (!entry) return null;
|
|
1297
|
+
// CANCEL-STICKY-TERMINAL: never resurrect a terminal row into an active state. A late
|
|
1298
|
+
// fire-and-forget writer (canonically the dispatch-failure `.catch` requeue to
|
|
1299
|
+
// 'pending' in mesh-queue-assignment.ts, which resolves AFTER a cancel commits) must
|
|
1300
|
+
// not undo a cancel/completion/failure — that revival let the row be re-claimed and the
|
|
1301
|
+
// reclaim watchdog re-drive the same prompt. Refuse the transition as a no-op unless an
|
|
1302
|
+
// explicit operator override is passed. This is the write-side sibling of the
|
|
1303
|
+
// status!=='assigned' guard reclaimStrandedAssignedTask already applies.
|
|
1304
|
+
if (!opts?.force
|
|
1305
|
+
&& TERMINAL_TASK_STATUSES.has(entry.status)
|
|
1306
|
+
&& !TERMINAL_TASK_STATUSES.has(status)) {
|
|
1307
|
+
LOG.debug('MeshQueue', `Refusing updateTaskStatus(${taskId} → ${status}) on mesh ${meshId}: row is terminal (${entry.status}). A late writer (e.g. dispatch-failure requeue) must not resurrect a cancelled/completed/failed task. Pass force to override.`);
|
|
1308
|
+
return { entry, cascaded: [] as MeshWorkQueueEntry[] };
|
|
1309
|
+
}
|
|
1276
1310
|
entry.status = status;
|
|
1277
1311
|
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1278
1312
|
// Any transition OFF `assigned` ends the single-flight dispatch window (terminal
|
|
@@ -1313,18 +1347,67 @@ export function cancelTask(
|
|
|
1313
1347
|
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
1314
1348
|
if (!entry) return null;
|
|
1315
1349
|
const now = new Date().toISOString();
|
|
1350
|
+
// CANCEL-STICKY-TERMINAL (authoritative cancel): capture the prior assignment BEFORE
|
|
1351
|
+
// clearing it, so the caller can stop the bound live worker. Leaving assignedNodeId/
|
|
1352
|
+
// SessionId/ProviderType on the cancelled row let the still-running worker keep emitting
|
|
1353
|
+
// delivery/turn signals that re-ignited the reclaim watchdog (observed: nonce 6→9,
|
|
1354
|
+
// needing two cancels + a manual session stop). Clearing them also drops this row from
|
|
1355
|
+
// the status==='assigned' counters so it can never be treated as live again.
|
|
1356
|
+
const priorAssignment: CancelledTaskAssignment | undefined = entry.assignedSessionId
|
|
1357
|
+
? {
|
|
1358
|
+
sessionId: entry.assignedSessionId,
|
|
1359
|
+
nodeId: entry.assignedNodeId,
|
|
1360
|
+
providerType: entry.assignedProviderType,
|
|
1361
|
+
}
|
|
1362
|
+
: undefined;
|
|
1316
1363
|
entry.status = 'cancelled';
|
|
1317
1364
|
entry.cancelledAt = now;
|
|
1318
1365
|
if (opts?.reason) entry.cancelReason = opts.reason;
|
|
1366
|
+
delete entry.assignedNodeId;
|
|
1367
|
+
delete entry.assignedSessionId;
|
|
1368
|
+
delete entry.assignedProviderType;
|
|
1369
|
+
delete entry.dispatchTimestamp;
|
|
1370
|
+
// Belt-and-suspenders: bump the dispatch nonce so any in-flight inject the
|
|
1371
|
+
// now-orphaned worker later echoes carries a stale nonce and is rejected by the
|
|
1372
|
+
// coordinator's stale-nonce guard — same mechanism reclaimStrandedAssignedTask uses.
|
|
1373
|
+
entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
|
|
1319
1374
|
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
1320
1375
|
endTaskDispatchInFlight(meshId, taskId);
|
|
1321
1376
|
const cascaded = propagateDependencyFailure(meshId, taskId);
|
|
1322
|
-
return { entry, cascaded };
|
|
1377
|
+
return { entry, cascaded, priorAssignment };
|
|
1323
1378
|
});
|
|
1324
1379
|
if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
|
|
1380
|
+
// Surface the prior binding to the caller (out-of-band from the persisted row, so it is
|
|
1381
|
+
// never serialized) so the cancel command handler — which holds DaemonComponents — can
|
|
1382
|
+
// stop the now-orphaned worker via the transport-aware stopStaleMeshWorker helper.
|
|
1383
|
+
if (result?.priorAssignment) lastCancelledTaskAssignment.set(`${meshId}::${taskId}`, result.priorAssignment);
|
|
1325
1384
|
return result ? result.entry : null;
|
|
1326
1385
|
}
|
|
1327
1386
|
|
|
1387
|
+
/**
|
|
1388
|
+
* CANCEL-STICKY-TERMINAL: the assignment a task carried at cancel time, handed to the cancel
|
|
1389
|
+
* command handler so it can stop the bound worker. cancelTask runs in the pure queue-store
|
|
1390
|
+
* module (no DaemonComponents), so it records the binding here and the handler drains it.
|
|
1391
|
+
*/
|
|
1392
|
+
export interface CancelledTaskAssignment {
|
|
1393
|
+
sessionId: string;
|
|
1394
|
+
nodeId?: string;
|
|
1395
|
+
providerType?: string;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const lastCancelledTaskAssignment = new Map<string, CancelledTaskAssignment>();
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Read-and-clear the assignment a just-cancelled task was bound to. Returns undefined when the
|
|
1402
|
+
* cancelled task had no live assignment (nothing to stop). One-shot: the entry is deleted on read.
|
|
1403
|
+
*/
|
|
1404
|
+
export function takeCancelledTaskAssignment(meshId: string, taskId: string): CancelledTaskAssignment | undefined {
|
|
1405
|
+
const key = `${meshId}::${taskId}`;
|
|
1406
|
+
const value = lastCancelledTaskAssignment.get(key);
|
|
1407
|
+
if (value) lastCancelledTaskAssignment.delete(key);
|
|
1408
|
+
return value;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1328
1411
|
/**
|
|
1329
1412
|
* Return a queue task to pending for retry. By default, dead session targeting
|
|
1330
1413
|
* and assigned ownership are cleared so stale assignments do not strand again.
|
|
@@ -66,6 +66,15 @@ export type CompletedFinalizationBlock = {
|
|
|
66
66
|
// preamble summary (evidenceLevel=insufficient) into the append-only ledger before the
|
|
67
67
|
// worker's next approval turn resumes. Independent of valley length.
|
|
68
68
|
holdForTranscript?: boolean;
|
|
69
|
+
// (CANON-C EARLY-EMIT FLOOR) Set on a missing_final_assistant block whose provider has NO
|
|
70
|
+
// external transcript source to trail — a PTY-parsed provider (codex-cli) or one that merely
|
|
71
|
+
// requires a final assistant before idle. For those the CANON-C decoupled-immediate emit is a
|
|
72
|
+
// pure timing guess (no separate transcript will land to upgrade it), so it must observe the
|
|
73
|
+
// CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS floor rather than fire at the ~13s first-poll
|
|
74
|
+
// waitedMs. A native-source block (claude-cli external-native, where the transcript legitimately
|
|
75
|
+
// trails the idle transition by a write) deliberately does NOT set this — its immediate emit is
|
|
76
|
+
// correct and is upgraded by the transcript reconcile.
|
|
77
|
+
noExternalTranscriptSource?: boolean;
|
|
69
78
|
};
|
|
70
79
|
|
|
71
80
|
export type CompletionFinalAssistantEvidence = {
|
|
@@ -87,6 +96,25 @@ export type ExternalTranscriptProbe = {
|
|
|
87
96
|
|
|
88
97
|
export const COMPLETED_FINALIZATION_RETRY_MS = 1000;
|
|
89
98
|
export const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
|
|
99
|
+
// (CANON-C EARLY-EMIT FLOOR) Minimum elapsed time before the CANON-C transcript-evidence
|
|
100
|
+
// gate (allowTimeout blocks) may fire its decoupled-immediate completion for a block that
|
|
101
|
+
// has NO final-assistant evidence (missing_final_assistant, finalAssistantPresent=false).
|
|
102
|
+
//
|
|
103
|
+
// CANON-C deliberately decouples the idle NOTIFICATION from the transcript's final-assistant
|
|
104
|
+
// turn: for a native-source worker whose transcript write merely trails the idle transition,
|
|
105
|
+
// emitting immediately (weak, later upgraded) is correct. But the SAME allowTimeout path also
|
|
106
|
+
// carries codex/antigravity PLAIN terminal blocks whose on-screen "final" assistant never
|
|
107
|
+
// landed — there the immediate emit fires at whatever tiny waitedMs the first completion-gate
|
|
108
|
+
// poll observed (~13s live), stamping evidenceLevel=insufficient and racing the transcript
|
|
109
|
+
// before it can catch up or the 180s mesh-worker stall watchdog can arm. Requiring a minimum
|
|
110
|
+
// dwell gives the transcript a window to land (clearing the block for a GENUINE emit) and lets
|
|
111
|
+
// the stall watchdog own long turns, while still emitting a weak completion promptly once the
|
|
112
|
+
// floor is met so a genuinely-answerless turn is never wedged. A block WITH final-assistant
|
|
113
|
+
// evidence present is unaffected (it takes the normal emit path, not this floor). Scoped (at
|
|
114
|
+
// the call site) to the missing_final_assistant transcript-evidence gate on mesh workers.
|
|
115
|
+
// Sized so a short transcript-write lag resolves under it while staying well below the 30s
|
|
116
|
+
// COMPLETED_FINALIZATION_MAX_WAIT_MS cap that force-releases the weak completion regardless.
|
|
117
|
+
export const CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS = 20_000;
|
|
90
118
|
// (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
|
|
91
119
|
// sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
|
|
92
120
|
// flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
|
|
@@ -12,7 +12,7 @@ import * as fs from 'fs';
|
|
|
12
12
|
import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope } from './contracts.js';
|
|
13
13
|
import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
|
|
14
14
|
import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
|
|
15
|
-
import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
|
|
15
|
+
import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, resolveInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
|
|
16
16
|
import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
|
|
17
17
|
import { shortHash } from '../system/hash.js';
|
|
18
18
|
import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
STATUS_HYDRATION_TAIL_LIMIT,
|
|
57
57
|
COMPLETED_FINALIZATION_RETRY_MS,
|
|
58
58
|
COMPLETED_FINALIZATION_MAX_WAIT_MS,
|
|
59
|
+
CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS,
|
|
59
60
|
NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
|
|
60
61
|
PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
|
|
61
62
|
ANTIGRAVITY_HOLD_QUIET_DWELL_MS,
|
|
@@ -376,10 +377,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
376
377
|
// `waiting_choice` overlay in getState(); the raw adapter status stays idle/generating,
|
|
377
378
|
// so detectStatusTransition()'s status-keyed arms never fire and no push-worthy
|
|
378
379
|
// agent:* event is emitted — the owner misses the "ACTION REQUIRED" prompt when the
|
|
379
|
-
// app is backgrounded. We emit one agent:
|
|
380
|
-
// state (
|
|
381
|
-
//
|
|
382
|
-
//
|
|
380
|
+
// app is backgrounded. We emit one agent:waiting_choice on ENTRY into the prompt
|
|
381
|
+
// state (its own coordinator event, NOT the approval channel — a multi-choice
|
|
382
|
+
// question is answered with mesh_answer_question, never mesh_approve) carrying the
|
|
383
|
+
// FULL InteractivePrompt payload, and dedupe on this key so repeated status ticks
|
|
384
|
+
// with the same prompt do not re-fire. '' means no prompt is currently active
|
|
385
|
+
// (cleared when the prompt is answered/gone).
|
|
383
386
|
private lastInteractivePromptEventKey = '';
|
|
384
387
|
private autoApproveBusy = false;
|
|
385
388
|
private autoApproveBusyTimer: NodeJS.Timeout | null = null;
|
|
@@ -1272,7 +1275,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1272
1275
|
}
|
|
1273
1276
|
} else if (event === 'interactive_prompt_response' && data) {
|
|
1274
1277
|
try {
|
|
1275
|
-
|
|
1278
|
+
// mesh_answer_question (mission f1d25e11) sends a coordinator-friendly answer
|
|
1279
|
+
// form (per-question select by label/index) that must be resolved against the
|
|
1280
|
+
// AUTHORITATIVE active prompt held here. When the active prompt is present and
|
|
1281
|
+
// the promptId matches, resolve it; otherwise fall back to the strict keyed
|
|
1282
|
+
// form (dashboard local answers already send that shape).
|
|
1283
|
+
const response = (this.activeInteractivePrompt
|
|
1284
|
+
&& this.activeInteractivePrompt.promptId === (data as { promptId?: unknown })?.promptId
|
|
1285
|
+
&& Array.isArray((data as { answers?: unknown })?.answers))
|
|
1286
|
+
? resolveInteractivePromptResponse(this.activeInteractivePrompt, data)
|
|
1287
|
+
: normalizeInteractivePromptResponse(data);
|
|
1276
1288
|
if (this.activeInteractivePrompt?.promptId === response.promptId) {
|
|
1277
1289
|
this.activeInteractivePrompt = null;
|
|
1278
1290
|
}
|
|
@@ -2084,6 +2096,11 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2084
2096
|
reason: 'missing_final_assistant',
|
|
2085
2097
|
terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
|
|
2086
2098
|
allowTimeout: allowMissingAssistantTimeout,
|
|
2099
|
+
// PTY-parsed provider (codex-cli): no external transcript trails the idle
|
|
2100
|
+
// transition, so the CANON-C decoupled emit must observe the min-elapsed floor
|
|
2101
|
+
// rather than fire at the first-poll waitedMs. (claude-cli's external-native
|
|
2102
|
+
// branch above is exempt — its transcript legitimately lands moments later.)
|
|
2103
|
+
noExternalTranscriptSource: true,
|
|
2087
2104
|
};
|
|
2088
2105
|
}
|
|
2089
2106
|
}
|
|
@@ -2790,6 +2807,43 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2790
2807
|
this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
|
|
2791
2808
|
return;
|
|
2792
2809
|
}
|
|
2810
|
+
// (CANON-C EARLY-EMIT FLOOR) The transcript-evidence gate would otherwise fire its
|
|
2811
|
+
// decoupled-immediate completion at ANY waitedMs — including the ~13s a codex PLAIN
|
|
2812
|
+
// terminal `missing_final_assistant` block reaches on the first completion-gate poll,
|
|
2813
|
+
// with NO final assistant ever observed. That stamps a weak evidenceLevel=insufficient
|
|
2814
|
+
// completion the coordinator marks reviewRecommended but does not auto-hold, racing the
|
|
2815
|
+
// transcript before it can land and pre-empting the 180s mesh-worker stall watchdog.
|
|
2816
|
+
// Impose a minimum dwell — but ONLY for the no-external-transcript case
|
|
2817
|
+
// (noExternalTranscriptSource): a PTY-parsed provider has no separate transcript that
|
|
2818
|
+
// will land to upgrade the weak emit, so firing immediately is a pure timing guess. A
|
|
2819
|
+
// native-source block (claude-cli external-native) is deliberately NOT floored: its
|
|
2820
|
+
// transcript legitimately trails the idle transition by a write and the CANON-C
|
|
2821
|
+
// immediate emit is correct (upgraded by the reconcile). Keep holding (re-probe each
|
|
2822
|
+
// retry — the block clears for a GENUINE emit the moment the assistant turn lands) until
|
|
2823
|
+
// either the floor is met or the 30s cap force-releases the weak completion below.
|
|
2824
|
+
// Scoped to mesh worker sessions (the only place allowTimeout is set), so interactive
|
|
2825
|
+
// completion timing is untouched.
|
|
2826
|
+
if (isTranscriptEvidenceGate
|
|
2827
|
+
&& blockReason === 'missing_final_assistant'
|
|
2828
|
+
&& block.noExternalTranscriptSource === true
|
|
2829
|
+
&& waitedMs < CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS) {
|
|
2830
|
+
if (pending.loggedBlockReason !== 'canon_c_min_elapsed_floor') {
|
|
2831
|
+
LOG.info('CLI', `[${this.type}] holding CANON-C decoupled emit until min-elapsed floor (waitedMs=${waitedMs} floor=${CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS}); no final assistant yet (${blockReason})`);
|
|
2832
|
+
if (this.isMeshWorkerSession()) {
|
|
2833
|
+
traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `canon_c_min_elapsed_floor waited=${waitedMs}ms`);
|
|
2834
|
+
}
|
|
2835
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
|
|
2836
|
+
blockReason,
|
|
2837
|
+
latestVisibleStatus,
|
|
2838
|
+
terminal: block.terminal === true,
|
|
2839
|
+
canonCMinElapsedFloor: true,
|
|
2840
|
+
waitedMs,
|
|
2841
|
+
});
|
|
2842
|
+
pending.loggedBlockReason = 'canon_c_min_elapsed_floor';
|
|
2843
|
+
}
|
|
2844
|
+
this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2793
2847
|
// (ANTIGRAVITY-30S-CAP-PREMATURE) The 30s cap has been reached for an antigravity
|
|
2794
2848
|
// `holdForTranscript` block. The cap releases on ELAPSED TIME, not proof-of-idle — but
|
|
2795
2849
|
// antigravity is native-source (assistant answer in native-history) with a PTY-derived
|
|
@@ -3960,24 +4014,38 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3960
4014
|
this.lastStatus = newStatus;
|
|
3961
4015
|
}
|
|
3962
4016
|
|
|
3963
|
-
// INTERACTIVE-
|
|
3964
|
-
// ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to
|
|
3965
|
-
// status-change block above: an AskUserQuestion prompt is surfaced only as a
|
|
4017
|
+
// INTERACTIVE-QUESTION-EMIT: fire a coordinator/push-worthy notification when the
|
|
4018
|
+
// session ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to
|
|
4019
|
+
// the status-change block above: an AskUserQuestion prompt is surfaced only as a
|
|
3966
4020
|
// display-only `waiting_choice` overlay in getState() (mirrored here off
|
|
3967
4021
|
// adapterStatus.activeInteractivePrompt, the exact signal getState uses), while
|
|
3968
4022
|
// the raw adapter status stays idle/generating — so none of the status-keyed
|
|
3969
4023
|
// arms above ever emit an agent:* event for it and the owner gets no web-push.
|
|
3970
4024
|
//
|
|
3971
|
-
//
|
|
3972
|
-
// choice labels as modalButtons)
|
|
3973
|
-
//
|
|
4025
|
+
// MESH-QUESTION-ANSWER-PATH (mission f1d25e11): this used to REUSE
|
|
4026
|
+
// agent:waiting_approval (question as modalMessage, choice labels as modalButtons).
|
|
4027
|
+
// That mis-classified a multi-choice QUESTION as an approval — the coordinator saw
|
|
4028
|
+
// a task_approval_needed ledger row and tried mesh_approve, which cannot answer a
|
|
4029
|
+
// question (it resolves a yes/no modal). We now emit a DISTINCT
|
|
4030
|
+
// agent:waiting_choice event carrying the FULL InteractivePrompt payload
|
|
4031
|
+
// (promptId + every question's header/question/multiSelect and each option's
|
|
4032
|
+
// label/description) so the coordinator can render the choices and answer with the
|
|
4033
|
+
// dedicated mesh_answer_question tool. The rich payload survives the cross-machine
|
|
4034
|
+
// relay (see buildRelayMetadataEvent) so a REMOTE worker's question reaches the
|
|
4035
|
+
// coordinator with its options intact.
|
|
4036
|
+
//
|
|
4037
|
+
// We keep modalMessage/modalButtons on the event too so the existing server
|
|
4038
|
+
// web-push path (which reads those two fields) still fires with no cloud change —
|
|
4039
|
+
// but the payload's `interactivePrompt` is the authoritative, structured signal.
|
|
3974
4040
|
//
|
|
3975
4041
|
// Edge-triggered: emit exactly once on entry, keyed on promptId + question text,
|
|
3976
4042
|
// and reset the key when the prompt clears so a fresh prompt re-fires and a later
|
|
3977
4043
|
// real completion still flows through the idle arm normally. We do NOT emit when
|
|
3978
4044
|
// the session is ALSO in a genuine approval state (newStatus === 'waiting_approval'):
|
|
3979
|
-
// that arm already emits agent:waiting_approval
|
|
3980
|
-
//
|
|
4045
|
+
// that arm already emits agent:waiting_approval. A question (waiting_choice) and a
|
|
4046
|
+
// real approval (waiting_approval) therefore NEVER both fire for the same tick —
|
|
4047
|
+
// the approval arm wins and this arm yields (owner requirement: the two are
|
|
4048
|
+
// mutually exclusive).
|
|
3981
4049
|
const interactivePrompt = adapterStatus.activeInteractivePrompt ?? null;
|
|
3982
4050
|
if (interactivePrompt && newStatus !== 'waiting_approval') {
|
|
3983
4051
|
const firstQuestion = interactivePrompt.questions?.[0];
|
|
@@ -3991,7 +4059,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3991
4059
|
: undefined;
|
|
3992
4060
|
const modalButtons = firstQuestion?.options?.map((option: { label: string }) => option.label);
|
|
3993
4061
|
this.pushEvent({
|
|
3994
|
-
event: 'agent:
|
|
4062
|
+
event: 'agent:waiting_choice', chatTitle, timestamp: now,
|
|
4063
|
+
// Structured, authoritative payload — the coordinator renders these and
|
|
4064
|
+
// answers via mesh_answer_question. Carried whole (all questions, all
|
|
4065
|
+
// options) so multi-question / multi-select prompts round-trip fully.
|
|
4066
|
+
interactivePrompt,
|
|
4067
|
+
promptId: interactivePrompt.promptId,
|
|
4068
|
+
multiSelect: firstQuestion?.multiSelect === true,
|
|
4069
|
+
// Push-notification-friendly projection (server push path reads these).
|
|
3995
4070
|
modalMessage,
|
|
3996
4071
|
modalButtons,
|
|
3997
4072
|
});
|
|
@@ -210,10 +210,33 @@ function buttonBlockApprovalCue(spec: ModalSpec, text: string): boolean {
|
|
|
210
210
|
return hasNegativeApprovalOption(labels);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: an
|
|
215
|
+
* AskUserQuestion multi-choice picker is NOT an approval modal. Its option rows
|
|
216
|
+
* ("❯ 1. label") can otherwise satisfy the approval button cue and get
|
|
217
|
+
* mis-classified as `waiting_approval`, so the worker's question is surfaced to
|
|
218
|
+
* the coordinator as a task_approval_needed (→ mesh_approve, which cannot answer
|
|
219
|
+
* it). The picker carries a distinctive signature the approval FSM never does:
|
|
220
|
+
* the claude TUI select footer ("Enter to select … Esc to cancel") together with
|
|
221
|
+
* the freeform escape hatch ("Type something" / "Chat about this"). When both are
|
|
222
|
+
* present the screen is a question picker — surfaced separately as
|
|
223
|
+
* waiting_choice — so the approval matchers must yield. Mirrors the legacy
|
|
224
|
+
* looksLikeSelectionPicker guard (cli-provider-instance.ts) ported to SDK-v1.
|
|
225
|
+
*/
|
|
226
|
+
export function isAskUserQuestionPickerSignature(text: string): boolean {
|
|
227
|
+
if (!text) return false;
|
|
228
|
+
const hasSelectFooter = /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
|
|
229
|
+
if (!hasSelectFooter) return false;
|
|
230
|
+
return /Type something\.?|Chat about this/i.test(text);
|
|
231
|
+
}
|
|
232
|
+
|
|
213
233
|
function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
|
|
214
234
|
// Status-level modal detection is cue-only — does the question appear at all?
|
|
215
235
|
// Button extraction lives in buildParseApprovalFromTui.
|
|
216
236
|
const text = input.screenText ?? '';
|
|
237
|
+
// A question picker (AskUserQuestion) is never an approval — bail before any
|
|
238
|
+
// approval cue can match its numbered option rows (mission f1d25e11).
|
|
239
|
+
if (isAskUserQuestionPickerSignature(text)) return false;
|
|
217
240
|
const question = compile(spec.questionPattern, spec.questionFlags ?? 'i');
|
|
218
241
|
if (question.test(text)) return true;
|
|
219
242
|
for (const variant of spec.questionVariants ?? []) {
|
|
@@ -78,6 +78,22 @@ function compile(re: string, flags?: string): RegExp {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: mirror
|
|
83
|
+
* detect-status.isAskUserQuestionPickerSignature at the button-parse layer. A
|
|
84
|
+
* multi-choice AskUserQuestion picker is not an approval modal; its numbered
|
|
85
|
+
* option rows can otherwise be extracted as approval buttons and produce a
|
|
86
|
+
* spurious approval modal. When the picker signature (claude TUI select footer +
|
|
87
|
+
* "Type something"/"Chat about this" freeform escape hatch) is present, return
|
|
88
|
+
* null early so the screen is surfaced as waiting_choice, never approval.
|
|
89
|
+
*/
|
|
90
|
+
function isAskUserQuestionPickerSignature(text: string): boolean {
|
|
91
|
+
if (!text) return false;
|
|
92
|
+
const hasSelectFooter = /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
|
|
93
|
+
if (!hasSelectFooter) return false;
|
|
94
|
+
return /Type something\.?|Chat about this/i.test(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
function findQuestionLineIndex(
|
|
82
98
|
spec: ModalTuiSpec,
|
|
83
99
|
lines: string[],
|
|
@@ -268,6 +284,10 @@ export function buildParseApprovalFromTui(
|
|
|
268
284
|
return function parseApproval(input: CliApprovalInput): CliApprovalModal | null {
|
|
269
285
|
const rawText = input.screenText ?? input.buffer ?? '';
|
|
270
286
|
if (!rawText) return null;
|
|
287
|
+
// An AskUserQuestion picker is not an approval — never extract its option
|
|
288
|
+
// rows as approval buttons (mission f1d25e11). Check the raw text before any
|
|
289
|
+
// visible-region scoping trims the footer that identifies the picker.
|
|
290
|
+
if (isAskUserQuestionPickerSignature(rawText)) return null;
|
|
271
291
|
const text = visibleRegion ? applyVisibleRegion(visibleRegion, rawText) : rawText;
|
|
272
292
|
const lines = text.split('\n');
|
|
273
293
|
const question = findQuestionLineIndex(spec, lines);
|
|
@@ -117,6 +117,83 @@ export function normalizeInteractivePromptResponse(raw: unknown): InteractivePro
|
|
|
117
117
|
return { promptId, answers };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a coordinator-friendly answer form into the strict, questionId-keyed
|
|
122
|
+
* InteractivePromptResponse the TUI/answer machinery consumes (mission f1d25e11).
|
|
123
|
+
*
|
|
124
|
+
* mesh_answer_question lets the coordinator answer against the option LABELS or
|
|
125
|
+
* 1-based INDEXES it saw in the agent:waiting_choice event, without having to
|
|
126
|
+
* reconstruct the exact questionId → selectedLabels map. This resolves that
|
|
127
|
+
* ergonomic form against the AUTHORITATIVE active prompt (the daemon holds it),
|
|
128
|
+
* so index/label resolution and question ordering are correct by construction.
|
|
129
|
+
*
|
|
130
|
+
* Accepted `raw` shapes:
|
|
131
|
+
* - The strict form ({ promptId, answers: { [questionId]: { selectedLabels } } })
|
|
132
|
+
* — passed straight to normalizeInteractivePromptResponse (back-compat).
|
|
133
|
+
* - The friendly form ({ promptId, answers: [ { questionId?, select?, freeform? } ] })
|
|
134
|
+
* — entries map to questions by questionId, else by array position. `select`
|
|
135
|
+
* is a label (string) / 1-based index (number) / array of either.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveInteractivePromptResponse(
|
|
138
|
+
prompt: InteractivePrompt,
|
|
139
|
+
raw: unknown,
|
|
140
|
+
): InteractivePromptResponse {
|
|
141
|
+
if (!raw || typeof raw !== 'object') throw new Error('Interactive prompt response must be an object');
|
|
142
|
+
const record = raw as Record<string, unknown>;
|
|
143
|
+
const promptId = readString(record.promptId);
|
|
144
|
+
if (!promptId) throw new Error('promptId must be a non-empty string');
|
|
145
|
+
if (promptId !== prompt.promptId) throw new Error('Interactive prompt response does not match active prompt');
|
|
146
|
+
// Strict (keyed-object) form → existing normalizer.
|
|
147
|
+
if (record.answers && typeof record.answers === 'object' && !Array.isArray(record.answers)) {
|
|
148
|
+
return normalizeInteractivePromptResponse(record);
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(record.answers)) throw new Error('answers must be an array or a questionId-keyed object');
|
|
151
|
+
|
|
152
|
+
const resolveOneLabel = (question: InteractiveQuestion, sel: unknown): string => {
|
|
153
|
+
if (typeof sel === 'number' && Number.isFinite(sel)) {
|
|
154
|
+
const idx = Math.trunc(sel) - 1; // 1-based
|
|
155
|
+
const option = question.options[idx];
|
|
156
|
+
if (!option) throw new Error(`Option index ${sel} out of range for ${question.questionId}`);
|
|
157
|
+
return option.label;
|
|
158
|
+
}
|
|
159
|
+
const label = readString(sel);
|
|
160
|
+
if (!label) throw new Error(`Empty selection for ${question.questionId}`);
|
|
161
|
+
// Exact label match first; fall back to a numeric string index.
|
|
162
|
+
const exact = question.options.find((o) => o.label === label);
|
|
163
|
+
if (exact) return exact.label;
|
|
164
|
+
const asIndex = Number(label);
|
|
165
|
+
if (Number.isInteger(asIndex)) {
|
|
166
|
+
const option = question.options[asIndex - 1];
|
|
167
|
+
if (option) return option.label;
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Unknown option for ${question.questionId}: ${label}`);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const answers: Record<string, InteractiveAnswer> = {};
|
|
173
|
+
const entries = record.answers as unknown[];
|
|
174
|
+
entries.forEach((entryRaw, index) => {
|
|
175
|
+
if (!entryRaw || typeof entryRaw !== 'object' || Array.isArray(entryRaw)) return;
|
|
176
|
+
const entry = entryRaw as Record<string, unknown>;
|
|
177
|
+
const questionId = readString(entry.questionId);
|
|
178
|
+
const question = (questionId ? prompt.questions.find((q) => q.questionId === questionId) : undefined)
|
|
179
|
+
?? prompt.questions[index];
|
|
180
|
+
if (!question) throw new Error(`No matching question for answer entry ${index}`);
|
|
181
|
+
const freeformText = readString(entry.freeform) ?? readString(entry.freeformText);
|
|
182
|
+
const selectedLabels: string[] = [];
|
|
183
|
+
const select = entry.select;
|
|
184
|
+
if (Array.isArray(select)) {
|
|
185
|
+
for (const sel of select) selectedLabels.push(resolveOneLabel(question, sel));
|
|
186
|
+
} else if (select !== undefined && select !== null) {
|
|
187
|
+
selectedLabels.push(resolveOneLabel(question, select));
|
|
188
|
+
}
|
|
189
|
+
answers[question.questionId] = {
|
|
190
|
+
selectedLabels,
|
|
191
|
+
...(freeformText ? { freeformText } : {}),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
return { promptId, answers };
|
|
195
|
+
}
|
|
196
|
+
|
|
120
197
|
export function buildClaudeInteractiveToolResult(response: InteractivePromptResponse): string {
|
|
121
198
|
return JSON.stringify({
|
|
122
199
|
type: 'user',
|