@adhdev/daemon-core 0.9.82-rc.405 → 0.9.82-rc.407
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/cli-state-engine.d.ts +1 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +1 -0
- package/dist/index.js +91 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +91 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cli-adapters/cli-state-engine.ts +10 -0
- package/src/cli-adapters/provider-cli-adapter.ts +5 -0
- package/src/mesh/mesh-reconcile-loop.ts +129 -14
- package/src/providers/cli-provider-instance.ts +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.407",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.407",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -111,6 +111,13 @@ export class CliStateEngine {
|
|
|
111
111
|
// queued in pendingOutbound and only flushed asynchronously after idle), so the
|
|
112
112
|
// completion event carries the correct id instead of the racy session scalar.
|
|
113
113
|
currentTurnTaskId: string | null = null;
|
|
114
|
+
// GENERATING-BOUNDARY (R4d): wall-clock when the most recently STARTED turn began
|
|
115
|
+
// (set by onTurnStarted, persists past completion until the next turn starts).
|
|
116
|
+
// The startup-grace idle-stayed synthesis anchors its window on when the FIRST turn
|
|
117
|
+
// STARTED — not on when it finished — so a turn dispatched a few seconds after the
|
|
118
|
+
// grace collapse and then running for a non-trivial duration is still attributed to
|
|
119
|
+
// the startup collapse even though its COMPLETION lands past a now-anchored window.
|
|
120
|
+
currentTurnStartedAt = 0;
|
|
114
121
|
activeModal: { message: string; buttons: string[] } | null = null;
|
|
115
122
|
|
|
116
123
|
// ── Approval ─────────────────────────────────────
|
|
@@ -247,6 +254,9 @@ export class CliStateEngine {
|
|
|
247
254
|
this.currentTurnTaskId = typeof turnScope.taskId === 'string' && turnScope.taskId.trim()
|
|
248
255
|
? turnScope.taskId
|
|
249
256
|
: null;
|
|
257
|
+
// R4d: stamp the turn-start moment so the startup-grace idle-stayed synthesis can
|
|
258
|
+
// anchor its window on dispatch time rather than completion time.
|
|
259
|
+
this.currentTurnStartedAt = Date.now();
|
|
250
260
|
this.responseEpoch += 1;
|
|
251
261
|
}
|
|
252
262
|
|
|
@@ -1988,6 +1988,11 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1988
1988
|
// reads this when stamping completion events so they carry the completing turn's
|
|
1989
1989
|
// task rather than the racy last-write-wins session scalar.
|
|
1990
1990
|
get currentTurnTaskId(): string | null { return this.engine.currentTurnTaskId; }
|
|
1991
|
+
// R4d: wall-clock when the most recently started turn began (persists past settle).
|
|
1992
|
+
// The provider instance's startup-grace idle-stayed synthesis anchors its window on
|
|
1993
|
+
// this so a delayed-dispatch first turn whose duration overruns the now-anchored
|
|
1994
|
+
// window is still attributed to the startup collapse.
|
|
1995
|
+
get currentTurnStartedAt(): number { return this.engine.currentTurnStartedAt; }
|
|
1991
1996
|
|
|
1992
1997
|
get responseEpoch(): number { return this.engine.responseEpoch; }
|
|
1993
1998
|
set responseEpoch(v: number) { this.engine.responseEpoch = v; }
|
|
@@ -115,7 +115,40 @@ function resolveReconcileIntervalMs(): number {
|
|
|
115
115
|
// Keyed by `${meshId}::${taskId}`. The map is pruned each PHASE-4 pass to the set of currently
|
|
116
116
|
// active dispatches, so a completed/pruned task's counter is dropped (no unbounded growth).
|
|
117
117
|
const REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH = 2;
|
|
118
|
-
|
|
118
|
+
|
|
119
|
+
// R4e (RECONCILE-SYNTH-PREEMPTS-COMPLETION, time hardening). The consecutive-tick guard above is a
|
|
120
|
+
// tick COUNT; at the 4s cadence it spans only ~8s, which a long worker turn can straddle with a
|
|
121
|
+
// mid-turn idle window (a CLI PTY inter-tool-call settle, or the final assistant text already
|
|
122
|
+
// rendered while the turn's generating_completed lifecycle close still lags). The worker's 5s
|
|
123
|
+
// generating heartbeat means an ~8s idle window is barely over one heartbeat gap, so the tick count
|
|
124
|
+
// alone let a 53s-turn synth fire ~11s BEFORE the worker's real completion emit (R4e live case). We
|
|
125
|
+
// add two TIME hurdles the tick-count cannot express, BOTH finite so a truly-dead worker that never
|
|
126
|
+
// emits is still eventually synthesized (notification-miss stays 0 — only DEFERRED, never dropped):
|
|
127
|
+
// - MIN_IDLE_SETTLE_MS: the worker must have read idle for at least this long since its FIRST idle
|
|
128
|
+
// observation. Comfortably exceeds the 5s heartbeat gap so a transient mid-turn idle window
|
|
129
|
+
// cannot satisfy it; a genuinely-settled or dead session keeps reading idle and crosses it.
|
|
130
|
+
// - ACKED_TURN_SETTLE_MS: an `acked` (generating_started) dispatch is never synthesized within
|
|
131
|
+
// this window of its ack (dispatch.updatedAt = the generating_started status flip), so a turn
|
|
132
|
+
// that only just started is never completed off an early idle blip.
|
|
133
|
+
// Both are read at call time (not module-const) so tests can tune them via env per case.
|
|
134
|
+
function resolveTunedReconcileMs(envName: string, def: number, min: number, max: number): number {
|
|
135
|
+
const raw = readNonEmptyString(process.env[envName]);
|
|
136
|
+
if (raw) {
|
|
137
|
+
const parsed = Number.parseInt(raw, 10);
|
|
138
|
+
if (Number.isFinite(parsed) && parsed >= min && parsed <= max) return parsed;
|
|
139
|
+
}
|
|
140
|
+
return def;
|
|
141
|
+
}
|
|
142
|
+
function resolveMinIdleSettleMs(): number {
|
|
143
|
+
return resolveTunedReconcileMs('MESH_INFLIGHT_MIN_IDLE_SETTLE_MS', 16_000, 0, 120_000);
|
|
144
|
+
}
|
|
145
|
+
function resolveAckedTurnSettleMs(): number {
|
|
146
|
+
return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_TURN_SETTLE_MS', 20_000, 0, 180_000);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Per-task in-flight idle debounce: the consecutive idle-read `count` AND the timestamp of the
|
|
150
|
+
// FIRST idle observation `firstIdleAtMs` (for the MIN_IDLE_SETTLE_MS hurdle).
|
|
151
|
+
const inFlightIdleObservationCounts = new Map<string, { count: number; firstIdleAtMs: number }>();
|
|
119
152
|
|
|
120
153
|
function inFlightSynthKey(meshId: string, taskId: string): string {
|
|
121
154
|
return `${meshId}::${taskId}`;
|
|
@@ -1231,6 +1264,50 @@ function readChatPayloadStatus(payload: Record<string, unknown> | null): string
|
|
|
1231
1264
|
return readNonEmptyString(payload?.status).toLowerCase();
|
|
1232
1265
|
}
|
|
1233
1266
|
|
|
1267
|
+
// R4e fix (3): peek the pending-events queue for a REAL (worker-emitted) terminal completion
|
|
1268
|
+
// already queued for a task — used to yield the in-flight synth to the worker's own emit. Broad
|
|
1269
|
+
// peek (no daemon-id scoping) matched precisely by taskId, so a worker stamp in any daemon-id form
|
|
1270
|
+
// is still recognized. Best-effort: a peek failure returns false (proceed to synth — never block
|
|
1271
|
+
// delivery). A prior SYNTH's still-queued pending event also names this taskId, but a synth always
|
|
1272
|
+
// writes its terminal ledger atomically, so hasTerminalLedgerAfterDispatch downstream already
|
|
1273
|
+
// no-ops that case — this guard is specifically for an as-yet-unledgered worker emit in flight.
|
|
1274
|
+
function realTerminalEmitPendingForTask(meshId: string, taskId: string): boolean {
|
|
1275
|
+
let pending: readonly PendingMeshCoordinatorEvent[];
|
|
1276
|
+
try {
|
|
1277
|
+
pending = getPendingMeshCoordinatorEvents(meshId);
|
|
1278
|
+
} catch {
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
return pending.some(e =>
|
|
1282
|
+
readNonEmptyString(e.metadataEvent?.taskId) === taskId
|
|
1283
|
+
&& (e.event === 'agent:generating_completed' || e.event === 'agent:stopped'));
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// R4e fix (2): one fresh read_chat status read for the worker session, via the same local/remote
|
|
1287
|
+
// transport PHASE 4 uses. Returns the lowercased status, or null when the read is inconclusive
|
|
1288
|
+
// (transport error, success:false, no payload) — callers treat null as "no new evidence, proceed".
|
|
1289
|
+
async function reprobeWorkerStatus(
|
|
1290
|
+
components: DaemonComponents,
|
|
1291
|
+
args: { isLocalNode: boolean; nodeDaemonId: string; readArgs: Record<string, unknown> },
|
|
1292
|
+
): Promise<string | null> {
|
|
1293
|
+
try {
|
|
1294
|
+
if (args.isLocalNode) {
|
|
1295
|
+
const r = await components.commandHandler.handle('read_chat', args.readArgs);
|
|
1296
|
+
if (r && (r as { success?: boolean }).success === false) return null;
|
|
1297
|
+
return readChatPayloadStatus(unwrapReadChatPayload(r));
|
|
1298
|
+
}
|
|
1299
|
+
if (components.dispatchMeshCommand) {
|
|
1300
|
+
const r = await components.dispatchMeshCommand(args.nodeDaemonId, 'read_chat', args.readArgs);
|
|
1301
|
+
const p = unwrapReadChatPayload(r);
|
|
1302
|
+
if (p && (p as { success?: boolean }).success === false) return null;
|
|
1303
|
+
return readChatPayloadStatus(p);
|
|
1304
|
+
}
|
|
1305
|
+
} catch {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
return null;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1234
1311
|
// PHASE 4 helper. For every active (non-terminal) direct dispatch this daemon
|
|
1235
1312
|
// hosts, confirm the worker session is idle via a read_chat and — if a final
|
|
1236
1313
|
// assistant summary is present but no terminal ledger exists for that dispatch —
|
|
@@ -1314,6 +1391,7 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1314
1391
|
// waiting_approval session is mid-turn — synthesizing a completion now would
|
|
1315
1392
|
// be wrong. (idle is the only status the MCP poll path reconciles too.)
|
|
1316
1393
|
const synthKey = inFlightSynthKey(mesh.id, taskId);
|
|
1394
|
+
const nowMs = Date.now();
|
|
1317
1395
|
if (readChatPayloadStatus(payload) !== 'idle') {
|
|
1318
1396
|
// Not idle → the worker is mid-turn. Reset any partial idle streak so a single
|
|
1319
1397
|
// idle blip during a long generation never accumulates toward the synth threshold.
|
|
@@ -1321,24 +1399,48 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1321
1399
|
continue;
|
|
1322
1400
|
}
|
|
1323
1401
|
|
|
1324
|
-
// RECONCILE-SYNTH-PREEMPTS-COMPLETION: a dispatch whose worker was OBSERVED
|
|
1325
|
-
// generating (the agent:generating_started ack flipped the row to 'acked') and has
|
|
1326
|
-
// terminal yet is potentially still in-flight — its `idle` read here may be a transient
|
|
1327
|
-
// mid-turn
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1402
|
+
// RECONCILE-SYNTH-PREEMPTS-COMPLETION (R4e hardened): a dispatch whose worker was OBSERVED
|
|
1403
|
+
// to start generating (the agent:generating_started ack flipped the row to 'acked') and has
|
|
1404
|
+
// no terminal yet is potentially still in-flight — its `idle` read here may be a transient
|
|
1405
|
+
// mid-turn window, not a settled completion. Before synthesizing for such a task we require
|
|
1406
|
+
// ALL of: (1) CONSECUTIVE idle observations (a flicker clears next tick, counter reset
|
|
1407
|
+
// above); (2) at least MIN_IDLE_SETTLE_MS elapsed since the FIRST idle observation (the time
|
|
1408
|
+
// hurdle the ~8s tick count cannot express — it would otherwise be straddled by a long
|
|
1409
|
+
// turn's mid-turn idle); and (3) at least ACKED_TURN_SETTLE_MS since the generating_started
|
|
1410
|
+
// ack, so a turn that only just started is never completed off an early idle blip. A
|
|
1411
|
+
// genuinely-settled (completed-but-lost) or dead session keeps reading idle and clears all
|
|
1412
|
+
// three within a few extra ticks, so notification-miss stays 0. A never-acked dispatch
|
|
1413
|
+
// (worker never started) is exempt — no in-flight generation to pre-empt; its lost-dispatch
|
|
1414
|
+
// case is covered by the downstream grace + stale-summary guards.
|
|
1333
1415
|
if (dispatch.status === 'acked') {
|
|
1334
|
-
const
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1416
|
+
const prior = inFlightIdleObservationCounts.get(synthKey);
|
|
1417
|
+
const firstIdleAtMs = prior?.firstIdleAtMs ?? nowMs;
|
|
1418
|
+
const idleStreak = (prior?.count ?? 0) + 1;
|
|
1419
|
+
inFlightIdleObservationCounts.set(synthKey, { count: idleStreak, firstIdleAtMs });
|
|
1420
|
+
const idleSettleMs = nowMs - firstIdleAtMs;
|
|
1421
|
+
const minIdleSettleMs = resolveMinIdleSettleMs();
|
|
1422
|
+
const ackedTurnSettleMs = resolveAckedTurnSettleMs();
|
|
1423
|
+
const ackedAtMs = Date.parse(readNonEmptyString(dispatch.updatedAt));
|
|
1424
|
+
const sinceAckMs = Number.isFinite(ackedAtMs) ? nowMs - ackedAtMs : Number.POSITIVE_INFINITY;
|
|
1425
|
+
const tickGuardMet = idleStreak >= REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH;
|
|
1426
|
+
const settleGuardMet = idleSettleMs >= minIdleSettleMs;
|
|
1427
|
+
const ackGuardMet = sinceAckMs >= ackedTurnSettleMs;
|
|
1428
|
+
if (!tickGuardMet || !settleGuardMet || !ackGuardMet) {
|
|
1429
|
+
LOG.info('MeshReconcile', `In-flight synth hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) idle ${idleStreak}/${REQUIRED_CONSECUTIVE_IDLE_TICKS_FOR_INFLIGHT_SYNTH} tick(s), settle ${Math.round(idleSettleMs / 1000)}s/${Math.round(minIdleSettleMs / 1000)}s, since-ack ${Number.isFinite(sinceAckMs) ? Math.round(sinceAckMs / 1000) + 's' : '∞'}/${Math.round(ackedTurnSettleMs / 1000)}s — deferring completion synth until the worker's turn genuinely settles (guards against a mid-turn idle window pre-empting the real completion)`);
|
|
1338
1430
|
continue;
|
|
1339
1431
|
}
|
|
1340
1432
|
}
|
|
1341
1433
|
|
|
1434
|
+
// R4e fix (3) — worker-emit priority. If the worker's REAL terminal emit for this task has
|
|
1435
|
+
// already arrived in the pending-events queue (queued for delivery to the coordinator) but
|
|
1436
|
+
// not yet written a terminal ledger, YIELD: let the genuine emit surface rather than racing
|
|
1437
|
+
// it with a synth that would win the taskId-anchored fingerprint dedup and mask it.
|
|
1438
|
+
if (realTerminalEmitPendingForTask(mesh.id, taskId)) {
|
|
1439
|
+
inFlightIdleObservationCounts.delete(synthKey);
|
|
1440
|
+
LOG.info('MeshReconcile', `Worker-emit priority: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) has a real terminal completion already queued — yielding synth to the worker's own emit`);
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1342
1444
|
const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
|
|
1343
1445
|
const evidence = extractFinalAssistantSummaryEvidence(messages);
|
|
1344
1446
|
if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
|
|
@@ -1369,6 +1471,19 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1369
1471
|
continue;
|
|
1370
1472
|
}
|
|
1371
1473
|
|
|
1474
|
+
// R4e fix (2) — live re-probe immediately before committing the synth. The idle
|
|
1475
|
+
// observations that satisfied the settle guards above accumulated over PRIOR ticks; do one
|
|
1476
|
+
// fresh read right now so a worker that resumed generating since this tick's first read is
|
|
1477
|
+
// never falsely completed off a stale snapshot. Best-effort: an inconclusive re-probe
|
|
1478
|
+
// (transport error/null) falls through to the synth — we already hold a valid idle read from
|
|
1479
|
+
// the top of THIS tick, so a re-probe failure must not re-introduce a notification-miss.
|
|
1480
|
+
const reprobeStatus = await reprobeWorkerStatus(components, { isLocalNode, nodeDaemonId, readArgs });
|
|
1481
|
+
if (reprobeStatus && reprobeStatus !== 'idle') {
|
|
1482
|
+
inFlightIdleObservationCounts.delete(synthKey);
|
|
1483
|
+
LOG.info('MeshReconcile', `Live re-probe defer: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read '${reprobeStatus}' at synth-commit time — worker resumed generating; deferring synth to a later tick`);
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1372
1487
|
const providerSessionId = readNonEmptyString(payload.providerSessionId);
|
|
1373
1488
|
const coordinatorDaemonId = selfIds.find(id => !!id);
|
|
1374
1489
|
try {
|
|
@@ -148,7 +148,9 @@ const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
|
|
|
148
148
|
// before collapsing to idle; a turn can be dispatched a few seconds AFTER that collapse
|
|
149
149
|
// (the live R4b miss: collapse at boot+8s, dispatch at boot+12.4s — already past a 12s
|
|
150
150
|
// boot-anchored window before the turn even started). Anchoring on the collapse moment
|
|
151
|
-
//
|
|
151
|
+
// covers dispatch-delay; R4d additionally anchors on the turn-START moment
|
|
152
|
+
// (engine.currentTurnStartedAt) so a non-trivial turn-DURATION cannot push the completion
|
|
153
|
+
// past a now-anchored window (the live rc.405 Probe2 miss). The strong discriminator is
|
|
152
154
|
// generatingStartedAt===0 (generating was never observed) AND a started-but-finished turn
|
|
153
155
|
// — the window only keeps the synthesized reason honest and scopes the synthesis to the
|
|
154
156
|
// boot collapse, so a much-later unobservably-fast turn is not mislabelled a startup collapse.
|
|
@@ -2468,11 +2470,35 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2468
2470
|
// idle. Normal turns that DO reach 'busy' set generatingStartedAt and are
|
|
2469
2471
|
// excluded; a queued-pending first turn that only runs after grace falls
|
|
2470
2472
|
// outside the window and completes normally via idle→busy→idle.
|
|
2473
|
+
//
|
|
2474
|
+
// R4d (the live rc.405 Probe2 miss): R4c anchored the window on the collapse
|
|
2475
|
+
// moment but still measured its END against `now` (the poll/completion time). The
|
|
2476
|
+
// helper only fires once the turn has FINISHED (!hasAdapterPendingResponse()), so
|
|
2477
|
+
// the first eligible poll happens at completion. When the first turn is dispatched
|
|
2478
|
+
// a few seconds after the collapse AND runs for a non-trivial duration, that
|
|
2479
|
+
// completion lands PAST the 12s now-anchored window even though the turn was a
|
|
2480
|
+
// genuine startup-grace first turn (live: collapse→dispatch +5.2s, turn ~11s →
|
|
2481
|
+
// completion at collapse+16.2s > 12s). Anchor the window on when the first turn
|
|
2482
|
+
// STARTED (engine.currentTurnStartedAt, set by onTurnStarted) instead: a turn that
|
|
2483
|
+
// STARTED within the collapse window is a startup-grace first turn no matter how
|
|
2484
|
+
// long it then ran. The now-anchored check is retained as a union so a fast turn
|
|
2485
|
+
// (dispatched+completed quickly within 12s of collapse) keeps firing too; both
|
|
2486
|
+
// close for a much-later turn, preserving the "don't mislabel a late fast turn"
|
|
2487
|
+
// honesty the window exists for.
|
|
2488
|
+
const firstTurnStartedAt = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
|
|
2489
|
+
? (this.adapter as any).currentTurnStartedAt as number
|
|
2490
|
+
: 0;
|
|
2491
|
+
const collapsedAt = this.startupGraceCollapseAt;
|
|
2492
|
+
const turnStartedWithinCollapseWindow = collapsedAt !== null
|
|
2493
|
+
&& firstTurnStartedAt > 0
|
|
2494
|
+
&& firstTurnStartedAt >= collapsedAt
|
|
2495
|
+
&& (firstTurnStartedAt - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
|
|
2496
|
+
const nowWithinCollapseWindow = collapsedAt !== null
|
|
2497
|
+
&& (now - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
|
|
2471
2498
|
if (
|
|
2472
2499
|
newStatus === 'idle'
|
|
2473
2500
|
&& previousStatus === 'idle'
|
|
2474
|
-
&&
|
|
2475
|
-
&& (now - this.startupGraceCollapseAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS
|
|
2501
|
+
&& (turnStartedWithinCollapseWindow || nowWithinCollapseWindow)
|
|
2476
2502
|
) {
|
|
2477
2503
|
this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_idle_turn_collapse');
|
|
2478
2504
|
}
|