@adhdev/daemon-core 1.0.18-rc.6 → 1.0.18-rc.8

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.
@@ -88,6 +88,14 @@ export type MeshLedgerKind =
88
88
  // payload: { keys: string[], hasDestructive: boolean, result: 'injected'|'refused'|'error',
89
89
  // refused?: string, submits?: boolean, confirmDestructive?: boolean }
90
90
  | 'key_injection'
91
+ // Disk/worktree retention (mission 86def38d): DETECTION-ONLY signal that a git
92
+ // worktree present on disk has no matching live mesh node — an orphan cleanup
93
+ // candidate. The reconcile loop emits this so the coordinator can decide whether
94
+ // to remove it; retention NEVER auto-deletes a worktree (manual/coordinator-driven).
95
+ // Keyed by worktreePath so a re-emit for the same orphan is idempotent (a prior
96
+ // unresolved entry within the dedupe window suppresses the repeat).
97
+ // payload: { worktreePath, branch?, head?, reason: 'no_matching_live_node', state: 'cleanup_candidate' }
98
+ | 'worktree_cleanup_candidate'
91
99
  ;
92
100
 
93
101
  export interface MeshLedgerEntry {
@@ -317,6 +325,70 @@ export const OPERATING_NOTE_DEDUPE_WINDOW = 40;
317
325
  // a coordinator actually sees while still capping unbounded store growth.
318
326
  export const OPERATING_NOTE_KEEP_LATEST = 100;
319
327
 
328
+ // ─── Operating-note lifecycle: category TTL + expiry (read-side only) ──────────
329
+ // Minimal first cut of the operating-notes lifecycle. Expiry is READ/INJECTION
330
+ // side ONLY — the store prune (keep-latest-100 above) stays purely count-based
331
+ // and NEVER deletes by age, so audit history is preserved. isNoteExpired decides
332
+ // whether an UNPINNED note still rides into a coordinator prompt.
333
+ //
334
+ // Per-category retention (days). A category not listed here — including the
335
+ // uncategorized case — is durable (never expires). provider_quirk is durable
336
+ // because a runtime quirk stays true until the provider changes.
337
+ export const OPERATING_NOTE_CATEGORY_TTL_DAYS: Readonly<Record<string, number>> = {
338
+ recovery_lesson: 14,
339
+ pattern_to_avoid: 30,
340
+ // provider_quirk: durable (intentionally absent → never expires)
341
+ };
342
+
343
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
344
+
345
+ /**
346
+ * Shape isNoteExpired reads. Structural so mesh-ledger stays free of a
347
+ * coordinator-prompt import (CoordinatorOperatingNote satisfies this).
348
+ */
349
+ export interface OperatingNoteExpiryInput {
350
+ category?: string;
351
+ pinned?: boolean;
352
+ createdAt?: string;
353
+ /** Explicit expiry override; wins over the category TTL when parseable. */
354
+ expiresAt?: string;
355
+ /** Fallback creation time (ledger entry timestamp) when createdAt absent. */
356
+ timestamp?: string;
357
+ }
358
+
359
+ /**
360
+ * Pure helper: is this UNPINNED operating note expired as of `now` (epoch ms)?
361
+ *
362
+ * Rules:
363
+ * - pinned notes NEVER expire (always false).
364
+ * - an explicit, parseable `expiresAt` in the past → expired.
365
+ * - otherwise the category TTL applies; a durable category (provider_quirk,
366
+ * uncategorized, or any category not in the TTL map) never expires.
367
+ * - age is measured from createdAt, falling back to `timestamp` (ledger entry
368
+ * time). If neither is a valid date, the note is treated as NOT expired
369
+ * (never silently drop a note we cannot age).
370
+ */
371
+ export function isNoteExpired(note: OperatingNoteExpiryInput, now: number): boolean {
372
+ if (!note || note.pinned) return false;
373
+
374
+ // Explicit expiresAt wins when present and parseable.
375
+ if (typeof note.expiresAt === 'string') {
376
+ const exp = new Date(note.expiresAt).getTime();
377
+ if (!Number.isNaN(exp)) return exp <= now;
378
+ }
379
+
380
+ const ttlDays = note.category ? OPERATING_NOTE_CATEGORY_TTL_DAYS[note.category] : undefined;
381
+ if (typeof ttlDays !== 'number' || !Number.isFinite(ttlDays)) {
382
+ // Durable category (provider_quirk / uncategorized / unknown) → never expires.
383
+ return false;
384
+ }
385
+
386
+ const created = new Date(note.createdAt ?? note.timestamp ?? '').getTime();
387
+ if (Number.isNaN(created)) return false; // cannot age → keep
388
+
389
+ return now - created >= ttlDays * MS_PER_DAY;
390
+ }
391
+
320
392
  // ─── Path Helpers ───────────────────────────────
321
393
 
322
394
  export function getLedgerDir(): string {
@@ -76,6 +76,7 @@ import {
76
76
  resolveReconcileIntervalMs,
77
77
  } from './mesh-reconcile-config.js';
78
78
  import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
79
+ import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
79
80
  import {
80
81
  reconcileUnterminatedDirectDispatches,
81
82
  autoPruneStaleDirectDispatches,
@@ -126,6 +127,16 @@ interface LiveCoordinator {
126
127
  // "restart does not clear it" symptom the operator needs visibility into).
127
128
  const coordinatorModalParkState = new Map<string, boolean>();
128
129
 
130
+ // Disk/worktree retention throttle. The reconcile tick runs every ~4s, but the
131
+ // retention sweep (fs walks + git worktree list) is far too heavy for that cadence
132
+ // and its artifacts age in days, so it runs at most once per hour. `undefined`
133
+ // means "never run yet" → runs on the first tick after daemon start so a long-lived
134
+ // backlog is reclaimed promptly rather than an hour later. Per-process; a restart
135
+ // re-runs it immediately, which is the desired behavior (a restart is exactly when
136
+ // stale artifacts from the previous run should be swept).
137
+ const DISK_RETENTION_INTERVAL_MS = 60 * 60 * 1000; // 1h
138
+ let lastDiskRetentionRunAt: number | undefined;
139
+
129
140
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
130
141
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
131
142
  const out: LiveCoordinator[] = [];
@@ -1354,6 +1365,44 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1354
1365
  }
1355
1366
  }
1356
1367
 
1368
+ // ── PHASE 6: disk / worktree retention (hourly, mission 86def38d) ──────────
1369
+ // Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime and grew
1370
+ // the data volume until a refine bootstrap failed at 98% disk. This throttled pass
1371
+ // (≤1×/hour — the artifacts age in days and the fs/git walk is too heavy for the 4s
1372
+ // tick) reclaims them:
1373
+ // • JSONL ledger files older than 30d (legacy after the SQLite ledger)
1374
+ // • terminated session-host runtime files older than 14d (live never touched)
1375
+ // • mesh-runtime.db.bak-* backups older than 7d
1376
+ // • orphan worktree DETECTION → cleanup_candidate ledger signal (NEVER deleted).
1377
+ // Runs BEFORE PHASE 2's early return so it fires whether or not a live CLI
1378
+ // coordinator exists on this daemon. Isolated in try/catch so it can never kill the
1379
+ // tick. All file deletions are defensive (age + live-runtime guards in the pure
1380
+ // selectors); worktree cleanup stays manual/coordinator-driven.
1381
+ {
1382
+ const nowMs = Date.now();
1383
+ const due = lastDiskRetentionRunAt === undefined
1384
+ || (nowMs - lastDiskRetentionRunAt) >= DISK_RETENTION_INTERVAL_MS;
1385
+ if (due) {
1386
+ lastDiskRetentionRunAt = nowMs;
1387
+ try {
1388
+ runDiskRetentionSweep(nowMs);
1389
+ } catch (e: any) {
1390
+ LOG.warn('MeshReconcile', `Disk retention sweep failed: ${e?.message || e}`);
1391
+ }
1392
+ // Orphan-worktree detection is per-mesh (needs the mesh's base repo + node set)
1393
+ // and only for meshes this daemon hosts (its local base checkout is the git anchor).
1394
+ for (const mesh of listMeshes()) {
1395
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1396
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1397
+ try {
1398
+ await detectAndSignalOrphanWorktrees(mesh, nowMs);
1399
+ } catch (e: any) {
1400
+ LOG.warn('MeshReconcile', `Orphan worktree detection failed for mesh ${mesh.id}: ${e?.message || e}`);
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+
1357
1406
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
1358
1407
  const coordinators = findLiveCoordinators(components);
1359
1408
  if (coordinators.length === 0) {
@@ -174,6 +174,27 @@ export class MeshRuntimeStore {
174
174
  this.instance = undefined;
175
175
  }
176
176
 
177
+ /**
178
+ * VACUUM the SQLite database to reclaim on-disk space. Retention prunes rows
179
+ * with DELETE, which frees pages inside the file but does NOT shrink it — the
180
+ * mesh-runtime.db grew to hundreds of MB (mission 86def38d disk-accumulation
181
+ * bootstrap failure) precisely because the file was never compacted. This
182
+ * rewrites the DB into a minimal footprint. Best-effort: a VACUUM failure (e.g.
183
+ * insufficient temp space, a read lock) is logged and swallowed so it can never
184
+ * block daemon shutdown. Called once on shutdown (see daemon-lifecycle), never
185
+ * on the hot path — VACUUM takes an exclusive lock and rewrites the whole file.
186
+ */
187
+ vacuum(): void {
188
+ try {
189
+ // Fold the WAL back into the main DB first so VACUUM reclaims those pages too.
190
+ try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* checkpoint best-effort */ }
191
+ this.db.exec('VACUUM;');
192
+ LOG.info('MeshRuntimeStore', 'VACUUM completed on shutdown');
193
+ } catch (err: any) {
194
+ LOG.warn('MeshRuntimeStore', `VACUUM on shutdown failed (ignored): ${err?.message || err}`);
195
+ }
196
+ }
197
+
177
198
  close(): void {
178
199
  this.db.close();
179
200
  }
@@ -120,6 +120,31 @@ export const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
120
120
  // met. Scoped (at the call site) to autonomous mesh sessions, so interactive sessions are
121
121
  // untouched.
122
122
  export const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
123
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Minimum PTY quiet dwell required before the
124
+ // COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) cap may RELEASE an antigravity `holdForTranscript`
125
+ // block into the forced weak completion. Antigravity is a native-source provider: its
126
+ // idle/generating verdict is PTY-screen-derived, but its assistant answer lands in
127
+ // native-history and can legitimately lag past 30s on a long turn (tool phase + slow reply).
128
+ // The 30s cap releases on ELAPSED TIME, not proof-of-idle — so on a long turn it fired a
129
+ // premature completed/idle to the coordinator while the PTY was still generating (live-repro:
130
+ // a routed RCA task emitted "completed" in 10s with no result). Require instead that the PTY
131
+ // has been quiet (no raw output) for at least this long AND the adapter reports no pending
132
+ // response before the cap may force-emit. A genuinely quiescent tool-only turn (no assistant
133
+ // bubble) has a stable screen well past this bound and still finalizes; a turn whose PTY is
134
+ // still producing output keeps holding past 30s (up to ANTIGRAVITY_HOLD_HARD_CAP_MS). Scoped
135
+ // (at the call site) to antigravity-cli holdForTranscript blocks, so claude-cli/codex-cli
136
+ // completion timing is untouched.
137
+ export const ANTIGRAVITY_HOLD_QUIET_DWELL_MS = 3000;
138
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Absolute upper bound on how long an antigravity
139
+ // `holdForTranscript` block may be held once the 30s cap is reached but the PTY is still
140
+ // active. The quiet-dwell gate above keeps holding while the PTY keeps emitting output, so a
141
+ // truly runaway turn (PTY never falls quiet) must still eventually release rather than wedge
142
+ // the session in generating forever — strictly worse than the original premature-emit bug.
143
+ // Once a pending completion has been held this long we force-emit regardless of PTY activity.
144
+ // Sized generously (5 min) so realistic long antigravity turns (multi-tool + slow reply)
145
+ // finish and let the transcript land / the PTY fall quiet naturally, while still guaranteeing
146
+ // eventual release. Mirrors BACKGROUND_TASK_HOLD_MAX_MS's escape-hatch rationale.
147
+ export const ANTIGRAVITY_HOLD_HARD_CAP_MS = 5 * 60_000;
123
148
  // (FALSE-IDLE-BACKGROUND-CMD) Hard cap on how long a pending completion may be HELD
124
149
  // solely because the claude-cli transcript still shows an unresolved run_in_background
125
150
  // bash job (backgroundTaskActive). The hold is the correct behaviour while the job is
@@ -58,6 +58,8 @@ import {
58
58
  COMPLETED_FINALIZATION_MAX_WAIT_MS,
59
59
  NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
60
60
  PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
61
+ ANTIGRAVITY_HOLD_QUIET_DWELL_MS,
62
+ ANTIGRAVITY_HOLD_HARD_CAP_MS,
61
63
  BACKGROUND_TASK_HOLD_MAX_MS,
62
64
  USER_INPUT_ACK_DEDUP_WINDOW_MS,
63
65
  STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS,
@@ -369,6 +371,16 @@ export class CliProviderInstance implements ProviderInstance {
369
371
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
370
372
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
371
373
  private lastApprovalEventFingerprint = '';
374
+ // INTERACTIVE-PROMPT-PUSH: edge-trigger key for the AskUserQuestion / waiting_choice
375
+ // notification. An AskUserQuestion prompt is surfaced only as a display-only
376
+ // `waiting_choice` overlay in getState(); the raw adapter status stays idle/generating,
377
+ // so detectStatusTransition()'s status-keyed arms never fire and no push-worthy
378
+ // agent:* event is emitted — the owner misses the "ACTION REQUIRED" prompt when the
379
+ // app is backgrounded. We emit one agent:waiting_approval on ENTRY into the prompt
380
+ // state (reusing the existing server push path, no cloud change) and dedupe on this
381
+ // key so repeated status ticks with the same prompt do not re-fire. '' means no
382
+ // prompt is currently active (cleared when the prompt is answered/gone).
383
+ private lastInteractivePromptEventKey = '';
372
384
  private autoApproveBusy = false;
373
385
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
374
386
  private lastAutoApprovalSignature = '';
@@ -1897,6 +1909,33 @@ export class CliProviderInstance implements ProviderInstance {
1897
1909
  return false;
1898
1910
  }
1899
1911
 
1912
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Discriminator gating the 30s-cap release of an antigravity
1913
+ // `holdForTranscript` block. Antigravity's idle verdict is PTY-screen-derived but its assistant
1914
+ // answer lands in native-history, which can legitimately lag past COMPLETED_FINALIZATION_MAX_WAIT_MS
1915
+ // (30s) on a long turn. The cap releases on elapsed time, not proof-of-idle, so it force-emitted a
1916
+ // premature weak completion WHILE THE PTY WAS STILL GENERATING. Returns true when the PTY is still
1917
+ // active — i.e. the adapter reports a pending response OR raw PTY output arrived within the last
1918
+ // ANTIGRAVITY_HOLD_QUIET_DWELL_MS — meaning the 30s cap must KEEP HOLDING (the turn is not proven
1919
+ // over). Returns false when the PTY is genuinely quiescent (no pending response AND no recent
1920
+ // output), so a real tool-only turn with no assistant bubble still force-emits a weak completion
1921
+ // rather than wedging. The absolute ANTIGRAVITY_HOLD_HARD_CAP_MS bound is enforced at the call site
1922
+ // so a runaway PTY that never falls quiet still eventually releases. Fails OPEN (returns false =
1923
+ // allow release) when lastOutputAt is unreadable, so the gate can never wedge a session.
1924
+ private antigravityHoldPtyStillActive(): boolean {
1925
+ if (this.hasAdapterPendingResponse()) return true;
1926
+ try {
1927
+ const outStatus = this.adapter.getStatus({ allowParse: false }) as any;
1928
+ const lastOutputAt = typeof outStatus?.lastOutputAt === 'number' && Number.isFinite(outStatus.lastOutputAt)
1929
+ ? outStatus.lastOutputAt as number
1930
+ : undefined;
1931
+ if (typeof lastOutputAt === 'number') {
1932
+ const quietMs = Date.now() - lastOutputAt;
1933
+ if (quietMs < ANTIGRAVITY_HOLD_QUIET_DWELL_MS) return true;
1934
+ }
1935
+ } catch { /* defensive: dwell read is best-effort — fall through to allow release */ }
1936
+ return false;
1937
+ }
1938
+
1900
1939
  private shouldSuppressStaleParsedBusyStatus(parsedStatus: any, adapterStatus: any): boolean {
1901
1940
  const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
1902
1941
  const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
@@ -2751,6 +2790,42 @@ export class CliProviderInstance implements ProviderInstance {
2751
2790
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
2752
2791
  return;
2753
2792
  }
2793
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) The 30s cap has been reached for an antigravity
2794
+ // `holdForTranscript` block. The cap releases on ELAPSED TIME, not proof-of-idle — but
2795
+ // antigravity is native-source (assistant answer in native-history) with a PTY-derived
2796
+ // idle verdict, so the transcript's final assistant bubble can legitimately land past 30s
2797
+ // on a long turn. Releasing here would fire a premature weak completed/idle to the
2798
+ // coordinator WHILE THE PTY IS STILL GENERATING (the live-reproduced defect). Gate the
2799
+ // release on the PTY being genuinely quiet: if it is still active (adapter pending
2800
+ // response OR raw output within ANTIGRAVITY_HOLD_QUIET_DWELL_MS) and we are under the
2801
+ // absolute ANTIGRAVITY_HOLD_HARD_CAP_MS bound, KEEP HOLDING (re-probe next retry — the
2802
+ // transcript branch above clears the block for a genuine emit once the answer lands).
2803
+ // A genuinely quiescent tool-only turn (no assistant bubble, PTY stable) falls through
2804
+ // to the existing weak force-emit; a runaway PTY that never quiets releases at the hard
2805
+ // cap so it cannot wedge forever. Scoped to antigravity holdForTranscript so
2806
+ // claude-cli/codex-cli completion timing is unchanged.
2807
+ if (this.type === 'antigravity-cli'
2808
+ && block.holdForTranscript === true
2809
+ && waitedMs < ANTIGRAVITY_HOLD_HARD_CAP_MS
2810
+ && this.antigravityHoldPtyStillActive()) {
2811
+ if (pending.loggedBlockReason !== 'antigravity_hold_pty_active') {
2812
+ LOG.info('CLI', `[${this.type}] 30s cap reached but PTY still generating; holding antigravity completion past cap (waitedMs=${waitedMs} hardCap=${ANTIGRAVITY_HOLD_HARD_CAP_MS}) (${blockReason})`);
2813
+ if (this.isMeshWorkerSession()) {
2814
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `antigravity_hold_pty_active waited=${waitedMs}ms`);
2815
+ }
2816
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
2817
+ blockReason,
2818
+ latestVisibleStatus,
2819
+ terminal: block.terminal === true,
2820
+ holdForTranscript: true,
2821
+ antigravityPtyStillActive: true,
2822
+ waitedMs,
2823
+ });
2824
+ pending.loggedBlockReason = 'antigravity_hold_pty_active';
2825
+ }
2826
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
2827
+ return;
2828
+ }
2754
2829
  const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
2755
2830
  const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
2756
2831
  blockReason,
@@ -3885,6 +3960,47 @@ export class CliProviderInstance implements ProviderInstance {
3885
3960
  this.lastStatus = newStatus;
3886
3961
  }
3887
3962
 
3963
+ // INTERACTIVE-PROMPT-PUSH: fire a push-worthy notification when the session
3964
+ // ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to the
3965
+ // status-change block above: an AskUserQuestion prompt is surfaced only as a
3966
+ // display-only `waiting_choice` overlay in getState() (mirrored here off
3967
+ // adapterStatus.activeInteractivePrompt, the exact signal getState uses), while
3968
+ // the raw adapter status stays idle/generating — so none of the status-keyed
3969
+ // arms above ever emit an agent:* event for it and the owner gets no web-push.
3970
+ //
3971
+ // We REUSE agent:waiting_approval (the question message as modalMessage, the
3972
+ // choice labels as modalButtons) so the existing server push path fires with
3973
+ // zero cloud change — the semantics ("the agent needs your input") match.
3974
+ //
3975
+ // Edge-triggered: emit exactly once on entry, keyed on promptId + question text,
3976
+ // and reset the key when the prompt clears so a fresh prompt re-fires and a later
3977
+ // real completion still flows through the idle arm normally. We do NOT emit when
3978
+ // the session is ALSO in a genuine approval state (newStatus === 'waiting_approval'):
3979
+ // that arm already emits agent:waiting_approval, and firing here too would double
3980
+ // up on the same modal.
3981
+ const interactivePrompt = adapterStatus.activeInteractivePrompt ?? null;
3982
+ if (interactivePrompt && newStatus !== 'waiting_approval') {
3983
+ const firstQuestion = interactivePrompt.questions?.[0];
3984
+ const promptKey = `${interactivePrompt.promptId}::${firstQuestion?.question ?? ''}`;
3985
+ if (promptKey !== this.lastInteractivePromptEventKey) {
3986
+ this.lastInteractivePromptEventKey = promptKey;
3987
+ const modalMessage = firstQuestion
3988
+ ? (firstQuestion.header
3989
+ ? `${firstQuestion.header}: ${firstQuestion.question}`
3990
+ : firstQuestion.question)
3991
+ : undefined;
3992
+ const modalButtons = firstQuestion?.options?.map((option: { label: string }) => option.label);
3993
+ this.pushEvent({
3994
+ event: 'agent:waiting_approval', chatTitle, timestamp: now,
3995
+ modalMessage,
3996
+ modalButtons,
3997
+ });
3998
+ }
3999
+ } else if (!interactivePrompt && this.lastInteractivePromptEventKey) {
4000
+ // Prompt answered / gone — reset so the next AskUserQuestion re-fires.
4001
+ this.lastInteractivePromptEventKey = '';
4002
+ }
4003
+
3888
4004
  // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
3889
4005
  // fast-collapse arm above only fires when the FIRST turn itself drives the
3890
4006
  // starting→idle transition. When the launch settle already drained