feat: add verified desktop ChatGPT subscription connection
This commit is contained in:
+66
-5
@@ -66,7 +66,9 @@ import {
|
||||
import type { TurnInput } from "../core/input-events.ts";
|
||||
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.ts";
|
||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { stores, effectiveSettings } from "./stores.ts";
|
||||
import { resolveConversationSpec } from "../core/settings-resolution.ts";
|
||||
import { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
import type { Agent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
@@ -111,6 +113,8 @@ interface WakeState {
|
||||
pumping: Set<string>;
|
||||
/** Throttle bookkeeping for the stale-job sweep. */
|
||||
lastSweepAt: number;
|
||||
stopConnectionWatch?: () => void;
|
||||
automaticDeferred: WeakSet<Agent>;
|
||||
}
|
||||
|
||||
interface LhWakeGlobal {
|
||||
@@ -119,7 +123,7 @@ interface LhWakeGlobal {
|
||||
|
||||
function state(): WakeState {
|
||||
const g = globalThis as typeof globalThis & LhWakeGlobal;
|
||||
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0 });
|
||||
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0, automaticDeferred: new WeakSet() });
|
||||
}
|
||||
|
||||
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
|
||||
@@ -210,9 +214,18 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
* beats the loop's synchronous start-poll drain and the marker rides the
|
||||
* run's FIRST LLM call (the native equivalent of the old continuation
|
||||
* coalescing). */
|
||||
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
|
||||
if (agent && state().automaticDeferred.has(agent)) return false;
|
||||
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
|
||||
return provider !== "chatgpt" || chatGPTBackgroundAllowed();
|
||||
}
|
||||
|
||||
function handOffEligible(chatId: string, agent: Agent): void {
|
||||
const queue = state().queues.get(chatId);
|
||||
if (queue && queue.length > 0) {
|
||||
// Keep automatic results in their owning registry. Never mix them into a
|
||||
// foreground batch without express account consent. Learner steers below
|
||||
// remain deliverable, including the first poll of an answer run.
|
||||
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
|
||||
for (const entry of queue) {
|
||||
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
||||
agent.steer(entry.steer);
|
||||
@@ -290,9 +303,25 @@ export function reconcileUserSteers(chatId: string, persisted: readonly LhMessag
|
||||
/** Clear the live agent at settlement — MUST run before settleSteers so the
|
||||
* reconciliation's re-pump can never hand a marker to the dying agent. */
|
||||
export function unregisterLiveAgent(chatId: string): void {
|
||||
const agent = state().liveAgents.get(chatId);
|
||||
if (agent) state().automaticDeferred.delete(agent);
|
||||
state().liveAgents.delete(chatId);
|
||||
}
|
||||
|
||||
/** Restore exact registry objects after native drain but before dispatch.
|
||||
* The vehicle is disposable; result ownership/promises are not. */
|
||||
export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>): void {
|
||||
const s = state();
|
||||
s.automaticDeferred.add(agent);
|
||||
for (const entry of s.queues.get(chatId) ?? []) {
|
||||
if (entry.handedTo === agent && automaticIds.has(entry.steer.id)) entry.handedTo = undefined;
|
||||
}
|
||||
agent.clearSteeringQueue();
|
||||
for (const entry of s.userSteers.get(chatId) ?? []) {
|
||||
if (!retainedIds.has(entry.message.id)) { agent.steer(entry.message); entry.handedTo = agent; }
|
||||
}
|
||||
}
|
||||
|
||||
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
|
||||
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
||||
*
|
||||
@@ -419,6 +448,7 @@ async function pump(chatId: string): Promise<void> {
|
||||
const s = state();
|
||||
if (s.pumping.has(chatId)) return;
|
||||
s.pumping.add(chatId);
|
||||
let parked = false;
|
||||
try {
|
||||
for (let attempt = 0; ; ) {
|
||||
const queue = s.queues.get(chatId);
|
||||
@@ -452,6 +482,7 @@ async function pump(chatId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
s.queues.set(chatId, stillPending);
|
||||
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
|
||||
|
||||
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
||||
// streaming turn takes every eligible marker; pi injects at the
|
||||
@@ -525,7 +556,8 @@ async function pump(chatId: string): Promise<void> {
|
||||
// hands them to that turn once the text-claim lands — until then,
|
||||
// settlement re-pumps and lands them right after it). The pause is
|
||||
// derived from the tail, so any user message un-holds by construction.
|
||||
if (endsInDismissal(fresh.messages)) {
|
||||
if (endsInDismissal(fresh.messages) || !automaticFollowUpAllowed(chatId)) {
|
||||
parked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -544,6 +576,15 @@ async function pump(chatId: string): Promise<void> {
|
||||
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
||||
return -1;
|
||||
});
|
||||
if (status === 403) {
|
||||
// Consent may have changed after the last gate. Preserve completed
|
||||
// job/result ownership and wait for an explicit connection change.
|
||||
const back = s.queues.get(chatId) ?? [];
|
||||
back.push(...fire);
|
||||
s.queues.set(chatId, back);
|
||||
parked = true;
|
||||
return;
|
||||
}
|
||||
if (status === 409) {
|
||||
const back = s.queues.get(chatId) ?? [];
|
||||
back.push(...fire);
|
||||
@@ -573,10 +614,30 @@ async function pump(chatId: string): Promise<void> {
|
||||
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
||||
// — restarting for them would just park in waitForIdle forever.
|
||||
const q = state().queues.get(chatId) ?? [];
|
||||
if (q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
||||
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
// Connection changes are the only wake for consent-deferred automatic work.
|
||||
// No retry timer, inference, or API-key fallback is started while consent is off.
|
||||
state().stopConnectionWatch?.();
|
||||
state().stopConnectionWatch = onChatGPTChange(() => {
|
||||
const s = state();
|
||||
for (const [chatId, agent] of s.liveAgents) {
|
||||
if (agent.state?.model?.provider !== "chatgpt" || automaticFollowUpAllowed(chatId, agent)) continue;
|
||||
const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
|
||||
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
|
||||
if (pending.length === 0) continue;
|
||||
agent.clearSteeringQueue();
|
||||
for (const entry of pending) entry.handedTo = undefined;
|
||||
for (const entry of s.userSteers.get(chatId) ?? []) if (!consumed.has(entry.message.id)) entry.handedTo = undefined;
|
||||
handOffEligible(chatId, agent); // only unconsumed learner input; never replay consumed steers
|
||||
}
|
||||
for (const chatId of s.queues.keys()) {
|
||||
if (automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) void pump(chatId);
|
||||
}
|
||||
});
|
||||
|
||||
/** Flip the job to its final status now that the lesson has resumed. */
|
||||
function finalizeJob(jobId: string): void {
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
|
||||
Reference in New Issue
Block a user