@adhdev/daemon-core 1.0.18-rc.9 → 1.0.18
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-adapters/provider-cli-shared.d.ts +33 -0
- package/dist/commands/cli-manager.d.ts +9 -0
- package/dist/commands/upgrade-helper.d.ts +1 -0
- package/dist/commands/windows-atomic-upgrade.d.ts +1 -0
- package/dist/config/mesh-json-config.d.ts +62 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2204 -657
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2172 -634
- 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-remote-event-pull.d.ts +3 -0
- package/dist/mesh/mesh-work-queue.d.ts +24 -1
- package/dist/providers/auto-approve-modes.d.ts +14 -0
- package/dist/providers/chat-message-normalization.d.ts +22 -0
- package/dist/providers/cli-provider-instance-types.d.ts +2 -0
- package/dist/providers/cli-provider-instance.d.ts +76 -0
- package/dist/providers/contracts.d.ts +17 -0
- package/dist/providers/provider-schema.d.ts +1 -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/dist/repo-mesh-types.d.ts +38 -6
- package/dist/shared-types.d.ts +4 -2
- package/package.json +3 -3
- package/src/cli-adapters/cli-state-engine.ts +17 -1
- package/src/cli-adapters/provider-cli-adapter.ts +2 -6
- package/src/cli-adapters/provider-cli-shared.ts +41 -0
- package/src/commands/cli-manager.ts +72 -8
- package/src/commands/high-family/mesh-events.ts +13 -2
- package/src/commands/med-family/mesh-crud.ts +155 -0
- 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/commands/upgrade-helper.ts +50 -1
- package/src/commands/windows-atomic-upgrade.ts +88 -23
- package/src/config/mesh-json-config.ts +103 -0
- package/src/index.ts +21 -1
- package/src/mesh/coordinator-prompt.ts +2 -1
- package/src/mesh/mesh-active-work.ts +11 -1
- package/src/mesh/mesh-completion-synthesis.ts +12 -1
- package/src/mesh/mesh-event-classify.ts +19 -0
- package/src/mesh/mesh-event-forwarding.ts +64 -6
- 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 +84 -10
- package/src/mesh/mesh-reconcile-loop.ts +257 -3
- package/src/mesh/mesh-refine-gates.ts +300 -0
- package/src/mesh/mesh-remote-event-pull.ts +68 -47
- package/src/mesh/mesh-work-queue.ts +85 -2
- package/src/providers/auto-approve-modes.ts +97 -0
- package/src/providers/chat-message-normalization.ts +53 -0
- package/src/providers/cli-provider-instance-types.ts +28 -0
- package/src/providers/cli-provider-instance.ts +394 -28
- package/src/providers/contracts.ts +25 -1
- package/src/providers/provider-schema.ts +83 -0
- 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/sdk/v1/schemas/cli/provider.schema.json +44 -0
- package/src/providers/types/interactive-prompt.ts +77 -0
- package/src/repo-mesh-types.ts +112 -12
- package/src/shared-types.ts +9 -1
- package/src/status/reporter.ts +1 -0
- package/src/status/snapshot.ts +2 -0
|
@@ -60,57 +60,78 @@ export async function pullRemoteNodeQueues(
|
|
|
60
60
|
// block the other nodes for the rest of the tick. Each node callback is fully
|
|
61
61
|
// self-contained (local/candidate skip, peer-connected pre-check, per-candidate
|
|
62
62
|
// pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
|
|
63
|
-
await Promise.allSettled(mesh.nodes.map(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// already in the local queue drained in PHASE 2). "This daemon" is matched
|
|
67
|
-
// against the full self-identity set (candidateDaemonIds), not just the bare
|
|
68
|
-
// localDaemonId — a self node can be registered under the config-form daemonId
|
|
69
|
-
// (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
|
|
70
|
-
// from ourselves over P2P is both wasteful and a self-dispatch hazard.
|
|
71
|
-
if (!nodeDaemonId) return;
|
|
72
|
-
if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
|
|
73
|
-
if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
|
|
63
|
+
await Promise.allSettled(mesh.nodes.map(node =>
|
|
64
|
+
pullPendingEventsFromNode(components, meshId, node, localDaemonId, candidateDaemonIds, pulls)));
|
|
65
|
+
}
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
// Pull one node's pending coordinator events and re-inject them locally via
|
|
68
|
+
// handleMeshForwardEvent (which runs the delivery-consume + re-queue paths). Factored
|
|
69
|
+
// out of pullRemoteNodeQueues so the reconcile loop can also issue a TARGETED single-node
|
|
70
|
+
// pull on demand — specifically, the DELIVERED-NOT-CONSUMED redrive gate calls this for
|
|
71
|
+
// one node right before it would re-drive, so a worker's already-emitted (but not-yet-
|
|
72
|
+
// pulled) agent:generating_started is consumed IN-PROCESS this tick instead of waiting for
|
|
73
|
+
// the next PHASE 1 pull. That closes the redrive-vs-consume race: the redrive gate reads
|
|
74
|
+
// taskDeliveryConsumed() AFTER this pull has had its chance to flip the delivery row.
|
|
75
|
+
// Returns silently on any skip/error — every caller treats it as best-effort.
|
|
76
|
+
export async function pullPendingEventsFromNode(
|
|
77
|
+
components: DaemonComponents,
|
|
78
|
+
meshId: string,
|
|
79
|
+
node: { daemonId?: string },
|
|
80
|
+
localDaemonId: string | undefined,
|
|
81
|
+
candidateDaemonIds: string[],
|
|
82
|
+
pulls: Array<Record<string, unknown>>,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
85
|
+
if (!dispatchMeshCommand) return;
|
|
86
|
+
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
87
|
+
// Skip nodes without a daemon, and nodes on THIS daemon (their events are
|
|
88
|
+
// already in the local queue drained in PHASE 2). "This daemon" is matched
|
|
89
|
+
// against the full self-identity set (candidateDaemonIds), not just the bare
|
|
90
|
+
// localDaemonId — a self node can be registered under the config-form daemonId
|
|
91
|
+
// (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
|
|
92
|
+
// from ourselves over P2P is both wasteful and a self-dispatch hazard.
|
|
93
|
+
if (!nodeDaemonId) return;
|
|
94
|
+
if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
|
|
95
|
+
if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
// Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
|
|
98
|
+
// a degraded peer whose DataChannel is not open would sink this pull into
|
|
99
|
+
// peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
|
|
100
|
+
// the whole serial loop and delaying completion-event recovery from healthy
|
|
101
|
+
// nodes. Skip such a node THIS tick and retry next tick — LOSSLESS: an
|
|
102
|
+
// unconnected peer has not drained anything (drained=0 preserved), so its events
|
|
103
|
+
// are recovered whole on the next successful tick. Skip = delay, never loss.
|
|
104
|
+
// • getter WIRED (cloud) → a null/undefined snapshot means "no peer object
|
|
105
|
+
// right now" = NOT connected (a powered-off node whose failPeer just deleted
|
|
106
|
+
// the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
|
|
107
|
+
// dialing here would re-queue for another 90s (the null-race the guard is
|
|
108
|
+
// meant to prevent). Only a snapshot with state === 'connected' proceeds.
|
|
109
|
+
// • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
|
|
110
|
+
// so this stays regression-free (the standalone case the guard's history
|
|
111
|
+
// references).
|
|
112
|
+
const getPeerStatus = components.getMeshPeerConnectionStatus;
|
|
113
|
+
if (getPeerStatus) {
|
|
114
|
+
const peerSnapshot = getPeerStatus(nodeDaemonId);
|
|
115
|
+
if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const pendingEventArgs of pulls) {
|
|
119
|
+
let events: unknown;
|
|
120
|
+
try {
|
|
121
|
+
events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
|
|
122
|
+
} catch {
|
|
123
|
+
// Remote pull is best-effort; the node may be offline. Retry next tick.
|
|
124
|
+
break; // node unreachable — don't bother with the other id form this tick.
|
|
125
|
+
}
|
|
126
|
+
const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
|
|
127
|
+
for (const event of list) {
|
|
128
|
+
const payload = buildForwardPayloadFromPending(event);
|
|
129
|
+
if (!payload.event || !payload.meshId) continue;
|
|
98
130
|
try {
|
|
99
|
-
|
|
100
|
-
} catch {
|
|
101
|
-
// Remote pull is best-effort; the node may be offline. Retry next tick.
|
|
102
|
-
break; // node unreachable — don't bother with the other id form this tick.
|
|
103
|
-
}
|
|
104
|
-
const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
|
|
105
|
-
for (const event of list) {
|
|
106
|
-
const payload = buildForwardPayloadFromPending(event);
|
|
107
|
-
if (!payload.event || !payload.meshId) continue;
|
|
108
|
-
try {
|
|
109
|
-
handleMeshForwardEvent(components, payload);
|
|
110
|
-
} catch { /* best-effort re-inject */ }
|
|
111
|
-
}
|
|
131
|
+
handleMeshForwardEvent(components, payload);
|
|
132
|
+
} catch { /* best-effort re-inject */ }
|
|
112
133
|
}
|
|
113
|
-
}
|
|
134
|
+
}
|
|
114
135
|
}
|
|
115
136
|
|
|
116
137
|
// Pull the read_chat payload out of whatever envelope the transport returned.
|
|
@@ -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.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutoApproveMode,
|
|
3
|
+
AutoApproveModeRisk,
|
|
4
|
+
AutoApproveModeStrategy,
|
|
5
|
+
ProviderModule,
|
|
6
|
+
} from './contracts.js';
|
|
7
|
+
|
|
8
|
+
const KNOWN_DANGEROUS_LAUNCH_ARGS = new Set([
|
|
9
|
+
'--dangerously-skip-permissions',
|
|
10
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
11
|
+
'bypassPermissions',
|
|
12
|
+
'sandbox_mode=danger-full-access',
|
|
13
|
+
'approval_policy=never',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export interface ResolvedAutoApproveMode {
|
|
17
|
+
active: boolean;
|
|
18
|
+
strategy: AutoApproveModeStrategy;
|
|
19
|
+
modeId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isKnownDangerousLaunchArg(arg: string): boolean {
|
|
23
|
+
const normalized = arg.trim();
|
|
24
|
+
if (KNOWN_DANGEROUS_LAUNCH_ARGS.has(normalized)) return true;
|
|
25
|
+
return normalized.startsWith('--dangerously-skip-permissions=')
|
|
26
|
+
|| normalized.startsWith('--dangerously-bypass-approvals-and-sandbox=');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Runtime defense-in-depth for provider definitions that bypass schema validation. */
|
|
30
|
+
export function deriveAutoApproveModeRisk(mode: Pick<AutoApproveMode, 'risk' | 'launchArgs'>): AutoApproveModeRisk {
|
|
31
|
+
return Array.isArray(mode.launchArgs) && mode.launchArgs.some(isKnownDangerousLaunchArg)
|
|
32
|
+
? 'dangerous'
|
|
33
|
+
: mode.risk;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function inactiveMode(modeId = ''): ResolvedAutoApproveMode {
|
|
37
|
+
return { active: false, strategy: 'pty-parse-default', modeId };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveConfiguredMode(
|
|
41
|
+
provider: ProviderModule,
|
|
42
|
+
mode: AutoApproveMode,
|
|
43
|
+
settings: Record<string, unknown> | undefined,
|
|
44
|
+
): ResolvedAutoApproveMode {
|
|
45
|
+
if (mode.strategy === 'post-boot-command') return inactiveMode(mode.id);
|
|
46
|
+
if (settings?.launchedByCoordinator === true
|
|
47
|
+
&& settings.delegatedWorkerDangerousModeAllow !== true
|
|
48
|
+
&& deriveAutoApproveModeRisk(mode) === 'dangerous') {
|
|
49
|
+
const fallback = provider.autoApproveModes?.modes.find((candidate) =>
|
|
50
|
+
candidate.strategy === 'pty-parse-default'
|
|
51
|
+
&& deriveAutoApproveModeRisk(candidate) !== 'dangerous');
|
|
52
|
+
return fallback
|
|
53
|
+
? { active: true, strategy: fallback.strategy, modeId: fallback.id }
|
|
54
|
+
: inactiveMode(mode.id);
|
|
55
|
+
}
|
|
56
|
+
return { active: true, strategy: mode.strategy, modeId: mode.id };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve new mode settings before the legacy boolean. A stale/unknown explicit
|
|
61
|
+
* mode id fails closed instead of falling through to an enabled legacy setting.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveProviderAutoApproveMode(
|
|
64
|
+
provider: ProviderModule,
|
|
65
|
+
settings: Record<string, unknown> | undefined,
|
|
66
|
+
): ResolvedAutoApproveMode {
|
|
67
|
+
const config = provider.autoApproveModes;
|
|
68
|
+
const explicitModeId = settings?.autoApproveMode;
|
|
69
|
+
if (typeof explicitModeId === 'string') {
|
|
70
|
+
const mode = config?.modes.find((candidate) => candidate.id === explicitModeId);
|
|
71
|
+
if (!mode) return inactiveMode(explicitModeId);
|
|
72
|
+
return resolveConfiguredMode(provider, mode, settings);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const explicitLegacy = settings?.autoApprove;
|
|
76
|
+
const providerLegacyDefault = provider.settings?.autoApprove?.default;
|
|
77
|
+
const legacyActive = typeof explicitLegacy === 'boolean'
|
|
78
|
+
? explicitLegacy
|
|
79
|
+
: typeof providerLegacyDefault === 'boolean'
|
|
80
|
+
? providerLegacyDefault
|
|
81
|
+
: false;
|
|
82
|
+
if (!legacyActive) return inactiveMode();
|
|
83
|
+
|
|
84
|
+
if (!config) {
|
|
85
|
+
return { active: true, strategy: 'pty-parse-default', modeId: 'legacy' };
|
|
86
|
+
}
|
|
87
|
+
const defaultMode = config.modes.find((mode) => mode.id === config.default);
|
|
88
|
+
if (!defaultMode) return inactiveMode(config.default);
|
|
89
|
+
return resolveConfiguredMode(provider, defaultMode, settings);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findProviderAutoApproveMode(
|
|
93
|
+
provider: ProviderModule | undefined,
|
|
94
|
+
modeId: string,
|
|
95
|
+
): AutoApproveMode | undefined {
|
|
96
|
+
return provider?.autoApproveModes?.modes.find((mode) => mode.id === modeId);
|
|
97
|
+
}
|
|
@@ -156,6 +156,59 @@ export function selectFinalAssistantTurnEndMessage(
|
|
|
156
156
|
return null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* EARLY-IDLE-COMPLETION-FALSE-POSITIVE — trailing tool/terminal activity detector.
|
|
161
|
+
*
|
|
162
|
+
* True when the transcript's LAST non-empty user-facing assistant bubble is followed
|
|
163
|
+
* by one or more TOOL / TERMINAL activity bubbles (a tool_use/command the assistant
|
|
164
|
+
* fired AFTER its text) — i.e. the assistant emitted a preamble ("Let me explore…"),
|
|
165
|
+
* then started running Read/Grep, so the turn is still executing and its answer has
|
|
166
|
+
* not landed. Callers that would otherwise promote that preamble to a turn-end summary
|
|
167
|
+
* off a momentary (startup-grace / inter-tool) idle read use this as a veto.
|
|
168
|
+
*
|
|
169
|
+
* Deliberately NARROW so the pure-PTY completion rescue is preserved:
|
|
170
|
+
* - Only TOOL/TERMINAL activity trailing the assistant vetoes. A trailing THOUGHT or
|
|
171
|
+
* status bubble does NOT (a finished turn can end on an internal thought), matching
|
|
172
|
+
* selectFinalAssistantTurnEndMessage's skip set minus the "still-working" signals.
|
|
173
|
+
* - A genuinely FINISHED worker (final assistant last, no trailing tool activity —
|
|
174
|
+
* the kimi pure-PTY continuous-idle shape) returns false, so its early completion
|
|
175
|
+
* is untouched.
|
|
176
|
+
*
|
|
177
|
+
* Returns false when there is no final assistant bubble at all (the caller's
|
|
178
|
+
* selectFinalAssistantTurnEndMessage already handles that as "not a turn end").
|
|
179
|
+
*/
|
|
180
|
+
export function hasTrailingToolActivityAfterFinalAssistant(
|
|
181
|
+
messages: ChatMessage[] | null | undefined,
|
|
182
|
+
): boolean {
|
|
183
|
+
if (!Array.isArray(messages) || messages.length === 0) return false;
|
|
184
|
+
let sawTrailingToolActivity = false;
|
|
185
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
186
|
+
const msg = messages[i];
|
|
187
|
+
if (!msg) continue;
|
|
188
|
+
const classification = classifyChatMessageVisibility(msg);
|
|
189
|
+
// A trailing user-facing assistant/model bubble with real text ends the scan:
|
|
190
|
+
// whether we saw a tool AFTER it is the verdict.
|
|
191
|
+
if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
|
|
192
|
+
if (flattenContent(msg.content).trim()) return sawTrailingToolActivity;
|
|
193
|
+
// Empty streaming assistant bubble — keep scanning back past it, same as
|
|
194
|
+
// selectFinalAssistantTurnEndMessage (which returns null here); an empty tail
|
|
195
|
+
// isn't a turn end, so there is nothing to veto.
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (classification.isUserFacing) {
|
|
199
|
+
// A trailing user bubble (freshly dispatched task, no reply) → no assistant
|
|
200
|
+
// turn end below it to veto.
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
// Non-user-facing activity/internal bubble sitting AFTER the (not-yet-seen)
|
|
204
|
+
// assistant bubble. Only tool/terminal activity signals an in-flight turn.
|
|
205
|
+
if (classification.kind === 'tool' || classification.kind === 'terminal') {
|
|
206
|
+
sawTrailingToolActivity = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
159
212
|
export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
|
|
160
213
|
|
|
161
214
|
export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
|
|
@@ -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
|