@adhdev/daemon-core 1.0.28-rc.13 → 1.0.28-rc.15

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.
@@ -67,4 +67,29 @@ export declare function pollAssignedTaskTerminalEvidence(components: DaemonCompo
67
67
  assignedNodeId?: string;
68
68
  assignedProviderType?: string;
69
69
  dispatchTimestamp?: string;
70
+ }, opts?: {
71
+ /**
72
+ * TX-FSM Stage 2 (EARLY-IDLE preamble guard): when set, the selected
73
+ * final assistant bubble must ALSO be at least this old at poll time.
74
+ *
75
+ * Why: the early-idle caller's continuous-idle streak proves the
76
+ * worker's status VERDICT stayed idle — not that the transcript did.
77
+ * A floor-class worker reads idle in the sliver BETWEEN an assistant
78
+ * preamble ("코드와 로그를 병행으로 확인하겠습니다.") and the tool call
79
+ * it is about to fire; at that instant the preamble IS the latest
80
+ * user-facing assistant bubble, is dated after dispatch, and has no
81
+ * trailing tool activity yet, so every structural guard above passes
82
+ * and a turn still in flight gets promoted to a completion (ledger
83
+ * 84594b15, 2026-07-26: task_completed with
84
+ * transcriptFinalAssistantPresent:false while the worker went on to an
85
+ * approval modal and did the real work minutes later). No single-read
86
+ * structural test can separate that preamble from a genuine final
87
+ * answer — only TIME can: a bubble younger than the settle window is
88
+ * narration, not a turn end. A genuinely finished worker's bubble
89
+ * simply completes one streak later (the caller resets and re-arms),
90
+ * so this guard DELAYS the early path by at most one window, never
91
+ * loses it. Fail-safe direction: veto → the caller falls through to
92
+ * the reclaim/grace paths.
93
+ */
94
+ minFinalAssistantAgeMs?: number;
70
95
  }): Promise<AssignedTaskTerminalEvidence | null>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Default lease bound: 180s. Rationale:
3
+ * - During genuine work the transcript keeps advancing, so every probe
4
+ * RE-ISSUES the lease and the bound never limits real work (the observed
5
+ * long-quiet completions — codex 363s PTY-quiet, a 4m48s hero worker, kimi
6
+ * 8m22s — all had continuously advancing transcripts; PTY quiet ≠ lease
7
+ * expiry).
8
+ * - The bound only starts mattering AFTER the transcript's last observed
9
+ * advance. It must comfortably cover the final-assistant write-lag on top
10
+ * of the 60s growth-quiet window (MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS),
11
+ * so 3× that window.
12
+ * - It must stay short enough that a genuinely wedged/finished session is not
13
+ * held busy indefinitely: 180s matches the idle stall-watchdog threshold, so
14
+ * a wedge surfaces on the same cadence as before Stage 2, and it is far
15
+ * below the 15-min reclaim deadline. Expiry returns the judgment to the
16
+ * unchanged floor/cap logic — the lease can never create an infinite busy.
17
+ */
18
+ export declare const BUSY_LEASE_BOUND_MS = 180000;
19
+ export interface BusyLeaseGate {
20
+ /** True when the bounded busy lease may gate judgments for this provider. */
21
+ enabled: boolean;
22
+ /** The lease bound in effect (default or env override). */
23
+ boundMs: number;
24
+ }
25
+ export declare function resolveBusyLeaseGate(providerType: string | undefined, env?: NodeJS.ProcessEnv): BusyLeaseGate;
@@ -632,6 +632,15 @@ export declare class CliProviderInstance implements ProviderInstance {
632
632
  * completion-debounce retry, never on the routine 5s tick.
633
633
  */
634
634
  private probeNativeTranscriptSignals;
635
+ /**
636
+ * TX-FSM Stage 2: is the bounded busy lease enabled for THIS provider?
637
+ * This is the canary rollout gate (busy-lease-gate.ts) — a per-provider
638
+ * feature switch, NOT a classification (transcript class/timing still come
639
+ * from resolveTranscriptAuthorityProfile only). Resolved per call so an
640
+ * env-driven rollout change takes effect without rebuilding the instance;
641
+ * any resolver error fails closed (lease disabled → pre-Stage-2 behavior).
642
+ */
643
+ private busyLeaseGateEnabled;
635
644
  /**
636
645
  * KIMI-MESH-COMPLETION-EMIT (axis 2). Last-chance completion emit for a mesh
637
646
  * DELEGATED worker whose PTY has already exited (e.g. killed by a false stall)
@@ -32,11 +32,37 @@ export interface TranscriptSignalSourceOpts {
32
32
  * completion pipeline's MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS so
33
33
  * the shadow signal is comparable to the existing growth-hold judgment. */
34
34
  growthQuietMs: number;
35
+ /** TX-FSM Stage 2: bound (ms) for the busy lease this source tracks.
36
+ * Defaults to BUSY_LEASE_BOUND_MS. */
37
+ leaseBoundMs?: number;
38
+ }
39
+ /** TX-FSM Stage 2 — the bounded busy lease state derived from the sample
40
+ * stream. The lease is ACTIVE while the most recent liveness-proving sample
41
+ * (in_turn_progress) is younger than the bound. It is deliberately kept OFF
42
+ * the SignalSnapshot envelope (the Stage-0 signal vocabulary is frozen and
43
+ * pinned): consumers read it via busyLease() on the source, which is pure
44
+ * memory — zero added I/O, and absent (never-issued) for any class the
45
+ * envelope already fails open for. */
46
+ export interface BusyLeaseState {
47
+ /** True while now < lastLiveAt + bound. False after expiry — the caller
48
+ * MUST then fall back to its normal (pre-lease) judgment. */
49
+ active: boolean;
50
+ /** Wall-clock of the last liveness-proving sample; null when no live
51
+ * sample has ever been observed (lease never issued). */
52
+ lastLiveAt: number | null;
53
+ /** lastLiveAt + bound; null when never issued. */
54
+ expiresAt: number | null;
55
+ /** max(0, expiresAt - now); 0 when expired or never issued. */
56
+ remainingMs: number;
35
57
  }
36
58
  export declare class TranscriptSignalSource {
37
59
  private readonly opts;
38
60
  /** msgCount seen at the previous update — drives in_turn_progress. */
39
61
  private prevMsgCount;
62
+ /** TX-FSM Stage 2: wall-clock of the most recent sample that proved the
63
+ * transcript live (in_turn_progress === true — a count advance OR mtime
64
+ * inside the growth-quiet window). -1 = the lease was never issued. */
65
+ private lastLiveSampleAt;
40
66
  /** Fingerprint of the last LOGGED snapshot, so the shadow log fires on
41
67
  * change only (a quiet session must not spam one line per read). */
42
68
  private lastLoggedFingerprint;
@@ -51,6 +77,25 @@ export declare class TranscriptSignalSource {
51
77
  /** Pure normalization — separated from update() so tests can assert the
52
78
  * envelope without touching the log path. */
53
79
  buildSnapshot(sample: TranscriptSignalSample, now: number): SignalSnapshot;
80
+ /**
81
+ * TX-FSM Stage 2 — the bounded busy lease, derived from the SAME sample
82
+ * stream that produces the envelope (zero added I/O, pure memory read).
83
+ *
84
+ * Semantics: every sample whose in_turn_progress is true (re)issues the
85
+ * lease at that sample's wall-clock; the lease stays ACTIVE for
86
+ * leaseBoundMs after the LAST such sample and then EXPIRES. The bound is
87
+ * the whole point: the lease extends busy across PTY-quiet stretches while
88
+ * the transcript is demonstrably alive, but it can never hold busy
89
+ * indefinitely — after expiry the consumer must resume its normal
90
+ * (pre-lease) judgment, so a finished-but-wedged session escapes on the
91
+ * bound, not on a transcript event that may never come.
92
+ *
93
+ * Fail-open by construction: a non-native-source class, an unresolved
94
+ * transcript, or a read error never reaches the issuance line, so the
95
+ * lease is simply never issued (active:false, lastLiveAt:null) — a
96
+ * missing lease must never wedge a session or fabricate one.
97
+ */
98
+ busyLease(now?: number): BusyLeaseState;
54
99
  /** Stage-0 shadow log: emit one line when the normalized observation
55
100
  * CHANGES. This log is the Stage 1-3 judgment input ("which signals were
56
101
  * actually observable, and when"), so it carries the full signal set —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.28-rc.13",
3
+ "version": "1.0.28-rc.15",
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",
@@ -49,8 +49,8 @@
49
49
  "author": "vilmire",
50
50
  "license": "AGPL-3.0-or-later",
51
51
  "dependencies": {
52
- "@adhdev/mesh-shared": "1.0.28-rc.13",
53
- "@adhdev/session-host-core": "1.0.28-rc.13",
52
+ "@adhdev/mesh-shared": "1.0.28-rc.15",
53
+ "@adhdev/session-host-core": "1.0.28-rc.15",
54
54
  "@agentclientprotocol/sdk": "^0.16.1",
55
55
  "ajv": "^8.20.0",
56
56
  "ajv-formats": "^3.0.1",
@@ -593,6 +593,32 @@ export async function pollAssignedTaskTerminalEvidence(
593
593
  components: DaemonComponents,
594
594
  mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
595
595
  row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string; dispatchTimestamp?: string },
596
+ opts?: {
597
+ /**
598
+ * TX-FSM Stage 2 (EARLY-IDLE preamble guard): when set, the selected
599
+ * final assistant bubble must ALSO be at least this old at poll time.
600
+ *
601
+ * Why: the early-idle caller's continuous-idle streak proves the
602
+ * worker's status VERDICT stayed idle — not that the transcript did.
603
+ * A floor-class worker reads idle in the sliver BETWEEN an assistant
604
+ * preamble ("코드와 로그를 병행으로 확인하겠습니다.") and the tool call
605
+ * it is about to fire; at that instant the preamble IS the latest
606
+ * user-facing assistant bubble, is dated after dispatch, and has no
607
+ * trailing tool activity yet, so every structural guard above passes
608
+ * and a turn still in flight gets promoted to a completion (ledger
609
+ * 84594b15, 2026-07-26: task_completed with
610
+ * transcriptFinalAssistantPresent:false while the worker went on to an
611
+ * approval modal and did the real work minutes later). No single-read
612
+ * structural test can separate that preamble from a genuine final
613
+ * answer — only TIME can: a bubble younger than the settle window is
614
+ * narration, not a turn end. A genuinely finished worker's bubble
615
+ * simply completes one streak later (the caller resets and re-arms),
616
+ * so this guard DELAYS the early path by at most one window, never
617
+ * loses it. Fail-safe direction: veto → the caller falls through to
618
+ * the reclaim/grace paths.
619
+ */
620
+ minFinalAssistantAgeMs?: number;
621
+ },
596
622
  ): Promise<AssignedTaskTerminalEvidence | null> {
597
623
  const sessionId = readNonEmptyString(row.assignedSessionId);
598
624
  const nodeId = readNonEmptyString(row.assignedNodeId);
@@ -631,6 +657,16 @@ export async function pollAssignedTaskTerminalEvidence(
631
657
  return null;
632
658
  }
633
659
 
660
+ // TX-FSM Stage 2 (EARLY-IDLE preamble guard): the final assistant bubble
661
+ // must have SETTLED — see the opts doc above. A bubble younger than the
662
+ // caller's settle window is treated as in-flight narration: veto (null),
663
+ // exactly like the trailing-tool guard, so the streak re-accumulates and a
664
+ // genuine turn end completes one window later.
665
+ if (typeof opts?.minFinalAssistantAgeMs === 'number' && opts.minFinalAssistantAgeMs > 0
666
+ && Date.now() - transcriptAtMs < opts.minFinalAssistantAgeMs) {
667
+ return null;
668
+ }
669
+
634
670
  // Idle + a final assistant message dated after dispatch = the worker finished this turn. We
635
671
  // cannot distinguish a self-reported failure from the plain transcript tail here (that lives
636
672
  // in buildTaskCompletionEvidence's structured-result path), and the alternative — re-driving a
@@ -1053,7 +1053,14 @@ async function recoverStrandedAssignedDispatches(
1053
1053
  if (since === undefined) {
1054
1054
  assignedIdleFinalAssistantSince.set(idleTranscriptStreakKey, nowMs);
1055
1055
  } else if (nowMs - since >= ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS) {
1056
- const terminalEvidence = await pollAssignedTaskTerminalEvidence(components, mesh, row);
1056
+ // TX-FSM Stage 2: the poll must additionally prove the final
1057
+ // assistant bubble has SETTLED for the full window (preamble
1058
+ // guard — see pollAssignedTaskTerminalEvidence). A too-fresh
1059
+ // bubble vetoes to null → the streak resets below and a genuine
1060
+ // turn end completes one window later.
1061
+ const terminalEvidence = await pollAssignedTaskTerminalEvidence(components, mesh, row, {
1062
+ minFinalAssistantAgeMs: ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS,
1063
+ });
1057
1064
  if (terminalEvidence) {
1058
1065
  assignedIdleFinalAssistantSince.delete(idleTranscriptStreakKey);
1059
1066
  updateTaskStatus(meshId, row.id, terminalEvidence.outcome);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TX-FSM Stage 2 — bounded busy lease rollout gate.
3
+ *
4
+ * The bounded busy lease (see transcript-signal-source.ts) changes FSM/completion
5
+ * JUDGMENT, so it does not ship to every provider at once: it is gated per
6
+ * provider type behind a canary list. The default canary is the Stage-2 goal's
7
+ * designated pair (kimi, codex-cli); the rollout widens by env, not by code:
8
+ *
9
+ * ADHDEV_TX_BUSY_LEASE_PROVIDERS — comma-separated provider types the lease
10
+ * is enabled for (REPLACES the default
11
+ * canary when set; set to an empty value
12
+ * like "-" to disable everywhere).
13
+ * ADHDEV_TX_BUSY_LEASE_BOUND_MS — lease bound override (field tuning;
14
+ * defaults to BUSY_LEASE_BOUND_MS).
15
+ *
16
+ * This is a ROLLOUT gate, not a classification: transcript class/timing still
17
+ * go through resolveTranscriptAuthorityProfile exclusively, and the lease only
18
+ * ever has data to act on for a native-source class (the signal source fails
19
+ * open otherwise). A provider absent from the list observes ZERO behaviour
20
+ * change — the lease branch is skipped before any lease state is consulted.
21
+ */
22
+ 'use strict';
23
+
24
+ /**
25
+ * Default lease bound: 180s. Rationale:
26
+ * - During genuine work the transcript keeps advancing, so every probe
27
+ * RE-ISSUES the lease and the bound never limits real work (the observed
28
+ * long-quiet completions — codex 363s PTY-quiet, a 4m48s hero worker, kimi
29
+ * 8m22s — all had continuously advancing transcripts; PTY quiet ≠ lease
30
+ * expiry).
31
+ * - The bound only starts mattering AFTER the transcript's last observed
32
+ * advance. It must comfortably cover the final-assistant write-lag on top
33
+ * of the 60s growth-quiet window (MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS),
34
+ * so 3× that window.
35
+ * - It must stay short enough that a genuinely wedged/finished session is not
36
+ * held busy indefinitely: 180s matches the idle stall-watchdog threshold, so
37
+ * a wedge surfaces on the same cadence as before Stage 2, and it is far
38
+ * below the 15-min reclaim deadline. Expiry returns the judgment to the
39
+ * unchanged floor/cap logic — the lease can never create an infinite busy.
40
+ */
41
+ export const BUSY_LEASE_BOUND_MS = 180_000;
42
+
43
+ /** Stage-2 goal canary: the lease ships to these provider types first. */
44
+ const DEFAULT_CANARY_PROVIDERS = ['kimi', 'codex-cli'];
45
+
46
+ export interface BusyLeaseGate {
47
+ /** True when the bounded busy lease may gate judgments for this provider. */
48
+ enabled: boolean;
49
+ /** The lease bound in effect (default or env override). */
50
+ boundMs: number;
51
+ }
52
+
53
+ export function resolveBusyLeaseGate(
54
+ providerType: string | undefined,
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ ): BusyLeaseGate {
57
+ const boundRaw = Number(env.ADHDEV_TX_BUSY_LEASE_BOUND_MS);
58
+ const boundMs = Number.isFinite(boundRaw) && boundRaw > 0 ? Math.floor(boundRaw) : BUSY_LEASE_BOUND_MS;
59
+ const listRaw = env.ADHDEV_TX_BUSY_LEASE_PROVIDERS;
60
+ const list = (typeof listRaw === 'string' ? listRaw : DEFAULT_CANARY_PROVIDERS.join(','))
61
+ .split(',')
62
+ .map(s => s.trim())
63
+ .filter(Boolean);
64
+ const type = (providerType ?? '').trim();
65
+ return { enabled: type.length > 0 && list.includes(type), boundMs };
66
+ }
@@ -19,6 +19,7 @@ import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js'
19
19
  import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
20
20
  import { resolveTranscriptAuthorityProfile } from './transcript-evidence.js';
21
21
  import { TranscriptSignalSource } from './transcript-signal-source.js';
22
+ import { resolveBusyLeaseGate } from './busy-lease-gate.js';
22
23
  import type { SignalSnapshot } from './spec/signal-envelope.js';
23
24
  import { createCliAdapter } from './spec/route.js';
24
25
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
@@ -1714,6 +1715,10 @@ export class CliProviderInstance implements ProviderInstance {
1714
1715
  // the message scan.
1715
1716
  finalAssistantPresent: (msgs, ts) => this.completionHasFinalAssistantMessage(msgs, ts),
1716
1717
  growthQuietMs: MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS,
1718
+ // TX-FSM Stage 2: the lease bound follows the rollout gate's
1719
+ // (possibly env-overridden) value; the gate's enabled flag is
1720
+ // consulted per judgment, not here.
1721
+ leaseBoundMs: resolveBusyLeaseGate(this.type).boundMs,
1717
1722
  });
1718
1723
  }
1719
1724
  const snapshot = this.transcriptSignalSource.update(
@@ -2727,6 +2732,18 @@ export class CliProviderInstance implements ProviderInstance {
2727
2732
  return { snapshot: this.lastTranscriptSignalSnapshot, messages };
2728
2733
  }
2729
2734
 
2735
+ /**
2736
+ * TX-FSM Stage 2: is the bounded busy lease enabled for THIS provider?
2737
+ * This is the canary rollout gate (busy-lease-gate.ts) — a per-provider
2738
+ * feature switch, NOT a classification (transcript class/timing still come
2739
+ * from resolveTranscriptAuthorityProfile only). Resolved per call so an
2740
+ * env-driven rollout change takes effect without rebuilding the instance;
2741
+ * any resolver error fails closed (lease disabled → pre-Stage-2 behavior).
2742
+ */
2743
+ private busyLeaseGateEnabled(): boolean {
2744
+ try { return resolveBusyLeaseGate(this.type).enabled; } catch { return false; }
2745
+ }
2746
+
2730
2747
  /**
2731
2748
  * KIMI-MESH-COMPLETION-EMIT (axis 2). Last-chance completion emit for a mesh
2732
2749
  * DELEGATED worker whose PTY has already exited (e.g. killed by a false stall)
@@ -3307,6 +3324,40 @@ export class CliProviderInstance implements ProviderInstance {
3307
3324
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
3308
3325
  return;
3309
3326
  }
3327
+ // TX-FSM Stage 2 (bounded busy lease): the transcript is NOT
3328
+ // growing right now (the branch above would have held), but a
3329
+ // sample within the lease bound proved it live — the turn is in
3330
+ // a PTY-quiet valley, not finished. For lease-gated providers
3331
+ // (canary rollout, resolveBusyLeaseGate), keep HOLDING the
3332
+ // missing_final_assistant emit until the lease EXPIRES; on
3333
+ // expiry this branch stops engaging and the unchanged floor/cap
3334
+ // logic below resumes — the lease extends busy, it can never
3335
+ // create an unbounded one (the agy busy-wedge failure mode).
3336
+ // Fail-open identically to the growth-hold: no usable snapshot,
3337
+ // no signal source, a never-issued lease, or a non-gated
3338
+ // provider all fall through untouched.
3339
+ if (growthSnapshot?.available === true && this.busyLeaseGateEnabled()) {
3340
+ const lease = this.transcriptSignalSource?.busyLease() ?? null;
3341
+ if (lease?.active === true) {
3342
+ if (pending.loggedBlockReason !== 'busy_lease_active') {
3343
+ LOG.info('CLI', `[${this.type}] holding pending completed (busy_lease_active: lastLiveAt=${lease.lastLiveAt} expiresIn=${lease.remainingMs}ms) — transcript live within the lease bound, screen-idle verdict not trusted`);
3344
+ if (this.isMeshWorkerSession()) {
3345
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `busy_lease_active lastLiveAt=${lease.lastLiveAt} expiresIn=${lease.remainingMs}ms`);
3346
+ }
3347
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
3348
+ blockReason: 'busy_lease_active',
3349
+ latestVisibleStatus,
3350
+ leaseLastLiveAt: lease.lastLiveAt,
3351
+ leaseExpiresAt: lease.expiresAt,
3352
+ leaseRemainingMs: lease.remainingMs,
3353
+ waitedMs,
3354
+ });
3355
+ pending.loggedBlockReason = 'busy_lease_active';
3356
+ }
3357
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
3358
+ return;
3359
+ }
3360
+ }
3310
3361
  }
3311
3362
  if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
3312
3363
  if (pending.loggedBlockReason !== blockReason) {
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { LOG } from '../logging/logger.js';
36
36
  import type { TranscriptAuthorityProfile } from './transcript-evidence.js';
37
+ import { BUSY_LEASE_BOUND_MS } from './busy-lease-gate.js';
37
38
  import {
38
39
  SIGNAL_SNAPSHOT_KIND,
39
40
  unavailableSignalSnapshot,
@@ -69,11 +70,38 @@ export interface TranscriptSignalSourceOpts {
69
70
  * completion pipeline's MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS so
70
71
  * the shadow signal is comparable to the existing growth-hold judgment. */
71
72
  growthQuietMs: number;
73
+ /** TX-FSM Stage 2: bound (ms) for the busy lease this source tracks.
74
+ * Defaults to BUSY_LEASE_BOUND_MS. */
75
+ leaseBoundMs?: number;
76
+ }
77
+
78
+ /** TX-FSM Stage 2 — the bounded busy lease state derived from the sample
79
+ * stream. The lease is ACTIVE while the most recent liveness-proving sample
80
+ * (in_turn_progress) is younger than the bound. It is deliberately kept OFF
81
+ * the SignalSnapshot envelope (the Stage-0 signal vocabulary is frozen and
82
+ * pinned): consumers read it via busyLease() on the source, which is pure
83
+ * memory — zero added I/O, and absent (never-issued) for any class the
84
+ * envelope already fails open for. */
85
+ export interface BusyLeaseState {
86
+ /** True while now < lastLiveAt + bound. False after expiry — the caller
87
+ * MUST then fall back to its normal (pre-lease) judgment. */
88
+ active: boolean;
89
+ /** Wall-clock of the last liveness-proving sample; null when no live
90
+ * sample has ever been observed (lease never issued). */
91
+ lastLiveAt: number | null;
92
+ /** lastLiveAt + bound; null when never issued. */
93
+ expiresAt: number | null;
94
+ /** max(0, expiresAt - now); 0 when expired or never issued. */
95
+ remainingMs: number;
72
96
  }
73
97
 
74
98
  export class TranscriptSignalSource {
75
99
  /** msgCount seen at the previous update — drives in_turn_progress. */
76
100
  private prevMsgCount = -1;
101
+ /** TX-FSM Stage 2: wall-clock of the most recent sample that proved the
102
+ * transcript live (in_turn_progress === true — a count advance OR mtime
103
+ * inside the growth-quiet window). -1 = the lease was never issued. */
104
+ private lastLiveSampleAt = -1;
77
105
  /** Fingerprint of the last LOGGED snapshot, so the shadow log fires on
78
106
  * change only (a quiet session must not spam one line per read). */
79
107
  private lastLoggedFingerprint = '';
@@ -138,6 +166,10 @@ export class TranscriptSignalSource {
138
166
  const inTurnProgress = countAdvanced || fresh;
139
167
  const transcriptGrowing = ageMs === null ? null : fresh;
140
168
  this.prevMsgCount = msgCount;
169
+ // TX-FSM Stage 2: a liveness-proving sample (re)issues the busy lease.
170
+ // transcript_growing === true implies fresh implies in_turn_progress,
171
+ // so in_turn_progress alone is the issuance condition.
172
+ if (inTurnProgress) this.lastLiveSampleAt = now;
141
173
 
142
174
  return {
143
175
  kind: SIGNAL_SNAPSHOT_KIND,
@@ -153,6 +185,34 @@ export class TranscriptSignalSource {
153
185
  };
154
186
  }
155
187
 
188
+ /**
189
+ * TX-FSM Stage 2 — the bounded busy lease, derived from the SAME sample
190
+ * stream that produces the envelope (zero added I/O, pure memory read).
191
+ *
192
+ * Semantics: every sample whose in_turn_progress is true (re)issues the
193
+ * lease at that sample's wall-clock; the lease stays ACTIVE for
194
+ * leaseBoundMs after the LAST such sample and then EXPIRES. The bound is
195
+ * the whole point: the lease extends busy across PTY-quiet stretches while
196
+ * the transcript is demonstrably alive, but it can never hold busy
197
+ * indefinitely — after expiry the consumer must resume its normal
198
+ * (pre-lease) judgment, so a finished-but-wedged session escapes on the
199
+ * bound, not on a transcript event that may never come.
200
+ *
201
+ * Fail-open by construction: a non-native-source class, an unresolved
202
+ * transcript, or a read error never reaches the issuance line, so the
203
+ * lease is simply never issued (active:false, lastLiveAt:null) — a
204
+ * missing lease must never wedge a session or fabricate one.
205
+ */
206
+ busyLease(now: number = Date.now()): BusyLeaseState {
207
+ if (this.lastLiveSampleAt < 0) {
208
+ return { active: false, lastLiveAt: null, expiresAt: null, remainingMs: 0 };
209
+ }
210
+ const bound = this.opts.leaseBoundMs ?? BUSY_LEASE_BOUND_MS;
211
+ const expiresAt = this.lastLiveSampleAt + bound;
212
+ const remainingMs = Math.max(0, expiresAt - now);
213
+ return { active: remainingMs > 0, lastLiveAt: this.lastLiveSampleAt, expiresAt, remainingMs };
214
+ }
215
+
156
216
  /** Stage-0 shadow log: emit one line when the normalized observation
157
217
  * CHANGES. This log is the Stage 1-3 judgment input ("which signals were
158
218
  * actually observable, and when"), so it carries the full signal set —