@adhdev/daemon-core 1.0.28-rc.12 → 1.0.28-rc.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +99 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +99 -7
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-completion-synthesis.d.ts +25 -0
- package/dist/providers/busy-lease-gate.d.ts +25 -0
- package/dist/providers/cli-provider-instance.d.ts +9 -0
- package/dist/providers/transcript-signal-source.d.ts +45 -0
- package/package.json +3 -3
- package/src/mesh/mesh-completion-synthesis.ts +36 -0
- package/src/mesh/mesh-reconcile-loop.ts +8 -1
- package/src/providers/busy-lease-gate.ts +66 -0
- package/src/providers/cli-provider-instance.ts +51 -0
- package/src/providers/transcript-signal-source.ts +60 -0
|
@@ -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.
|
|
3
|
+
"version": "1.0.28-rc.14",
|
|
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.
|
|
53
|
-
"@adhdev/session-host-core": "1.0.28-rc.
|
|
52
|
+
"@adhdev/mesh-shared": "1.0.28-rc.14",
|
|
53
|
+
"@adhdev/session-host-core": "1.0.28-rc.14",
|
|
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
|
-
|
|
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 —
|