diff --git a/.changeset/compaction-max-tokens-cap.md b/.changeset/compaction-max-tokens-cap.md new file mode 100644 index 00000000..a4ed5893 --- /dev/null +++ b/.changeset/compaction-max-tokens-cap.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Fix context compaction failing with provider "Invalid max_tokens" errors by capping requested completion tokens to the remaining context window and a safe output ceiling instead of the full context window size. diff --git a/.changeset/dynamic-workflow-progress-stall.md b/.changeset/dynamic-workflow-progress-stall.md new file mode 100644 index 00000000..84ac6198 --- /dev/null +++ b/.changeset/dynamic-workflow-progress-stall.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Fix Dynamic Workflow progress sticking at 90% during long streaming, show a Finalizing state once all delegated agents finish, and fix member row alignment at narrow widths. diff --git a/.changeset/homebrew-restart-updates.md b/.changeset/homebrew-restart-updates.md new file mode 100644 index 00000000..9d60e4bc --- /dev/null +++ b/.changeset/homebrew-restart-updates.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Prepare verified Homebrew updates in the background and install them automatically on the next interactive launch. diff --git a/apps/pythinker-code/src/cli/sub/doctor.ts b/apps/pythinker-code/src/cli/sub/doctor.ts index b0980ca6..cc691e07 100644 --- a/apps/pythinker-code/src/cli/sub/doctor.ts +++ b/apps/pythinker-code/src/cli/sub/doctor.ts @@ -15,9 +15,17 @@ import { z } from 'zod'; import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; import { readUpdateCache } from '#/cli/update/cache'; -import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from '#/cli/update/preflight'; +import { readUpdateInstallState } from '#/cli/update/install-state'; +import { + automaticUpdateModeFor, + isAutoUpdateDisabledByEnv, + shouldAutoInstallUpdates, + type AutomaticUpdateMode, +} from '#/cli/update/preflight'; import { detectInstallSource } from '#/cli/update/source'; +import type { UpdateInstallFailure } from '#/cli/update/types'; import { getHostPackageRoot, getVersion } from '#/cli/version'; +import { getUpdateInstallLogFile } from '#/utils/paths'; interface WritableLike { write(chunk: string): boolean; @@ -50,6 +58,12 @@ export interface DoctorRuntimeInfo { readonly latest: string | null; readonly checkedAt: string | null; readonly autoUpdate?: 'on' | 'off' | 'env-disabled'; + readonly mode?: AutomaticUpdateMode; + readonly pendingVersion?: string; + readonly pendingRequestedBy?: 'automatic' | 'manual'; + readonly activeOperation?: string; + readonly lastFailure?: string; + readonly logPath?: string; }; } @@ -166,11 +180,12 @@ function resolveDeps(deps: Partial | DoctorDeps | undefined): Resolv runtimeInfo: deps?.runtimeInfo ?? (async () => { - const [installSource, installations, ripgrep, update, autoInstall] = await Promise.all([ + const [installSource, installations, ripgrep, update, installState, autoInstall] = await Promise.all([ detectInstallSource(), findPythinkerExecutables(), findExistingRg(resolvePythinkerHome()), readUpdateCache(), + readUpdateInstallState(), shouldAutoInstallUpdates(), ]); return { @@ -184,12 +199,31 @@ function resolveDeps(deps: Partial | DoctorDeps | undefined): Resolv latest: update.latest, checkedAt: update.checkedAt, autoUpdate: isAutoUpdateDisabledByEnv() ? 'env-disabled' : autoInstall ? 'on' : 'off', + mode: automaticUpdateModeFor(installSource, process.platform), + pendingVersion: installState.pending?.version, + pendingRequestedBy: installState.pending?.requestedBy, + activeOperation: + installState.active === null + ? undefined + : `${installState.active.operation ?? 'install'} ${installState.active.version}`, + lastFailure: + installState.lastFailure === null + ? undefined + : formatUpdateFailure(installState.lastFailure), + logPath: getUpdateInstallLogFile(), }, }; }), }; } +function formatUpdateFailure(failure: UpdateInstallFailure): string { + const summary = `${failure.operation ?? 'install'} ${failure.version} ` + + `(attempt ${String(failure.attempts)})`; + const message = failure.message?.replaceAll(/\s+/gu, ' ').trim(); + return message === undefined || message === '' ? summary : `${summary}: ${message}`; +} + export async function findPythinkerExecutables( pathValue = process.env['PATH'], platform: NodeJS.Platform = process.platform, @@ -368,13 +402,7 @@ function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] { ? [] : [ ' Update channel: CDN staged rollout', - ...(info.update.autoUpdate === undefined - ? [] - : [ - info.update.autoUpdate === 'env-disabled' - ? ' Auto-update: disabled by PYTHINKER_CODE_NO_AUTO_UPDATE' - : ` Auto-update: ${info.update.autoUpdate} (tui.toml [upgrade].auto_install)`, - ]), + ...formatAutomaticUpdate(info), ...(info.update.latest === null ? [' Latest cached version: unavailable'] : [ @@ -382,11 +410,56 @@ function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] { info.update.checkedAt === null ? '' : ` (checked ${info.update.checkedAt})` }`, ]), + ...formatPreparedUpdate(info.update), + ...(info.update.activeOperation === undefined + ? [] + : [` Update operation: ${info.update.activeOperation}`]), + ...(info.update.lastFailure === undefined + ? [] + : [` Last update failure: ${info.update.lastFailure}`]), + ...(info.update.logPath === undefined ? [] : [` Update log: ${info.update.logPath}`]), ]), '', ]; } +function formatPreparedUpdate( + update: NonNullable, +): string[] { + if (update.pendingVersion === undefined) return []; + if (update.pendingRequestedBy === 'automatic' && update.autoUpdate !== 'on') { + return [ + ` Prepared update: ${update.pendingVersion} ` + + '(automatic activation paused until auto-update is enabled)', + ]; + } + return [` Prepared update: ${update.pendingVersion} (installs on next launch)`]; +} + +function formatAutomaticUpdate(info: DoctorRuntimeInfo): string[] { + const update = info.update; + if (update?.autoUpdate === undefined) return []; + if (update.autoUpdate === 'env-disabled') { + return [ + ' Auto-update: disabled by PYTHINKER_CODE_NO_AUTO_UPDATE or ' + + 'PYTHINKER_CLI_NO_AUTO_UPDATE', + ]; + } + if (update.autoUpdate === 'off') { + return [' Auto-update: off (tui.toml [upgrade].auto_install)']; + } + switch (update.mode) { + case 'restart-install': + return [' Auto-update: on (prepare in background; install on next launch)']; + case 'background-install': + return [' Auto-update: on (installs in background)']; + case 'manual': + return [` Auto-update: unavailable for ${info.installSource}`]; + case undefined: + return [' Auto-update: on (tui.toml [upgrade].auto_install)']; + } +} + function formatResults(results: readonly CheckResult[]): string[] { const lines: string[] = []; for (const result of results) { diff --git a/apps/pythinker-code/src/cli/update/activation.ts b/apps/pythinker-code/src/cli/update/activation.ts new file mode 100644 index 00000000..feef7428 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/activation.ts @@ -0,0 +1,181 @@ +import { gte, valid } from 'semver'; + +import { getUpdateInstallLogFile } from '#/utils/paths'; + +import { + activateHomebrewUpdate, + PreparedHomebrewUpdateInvalidError, +} from './homebrew'; +import { formatErrorMessage } from './format-error'; +import { tryAcquireUpdateInstallLock, type UpdateInstallLockHandle } from './install-lock'; +import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { detectInstallSource } from './source'; +import type { InstallSource, UpdateInstallState, UpdatePreparedHomebrew } from './types'; + +const ACTIVATION_FAILURE_LIMIT = 2; + +export interface ActivatePendingUpdateDeps { + readonly readState: () => Promise; + readonly writeState: (state: UpdateInstallState) => Promise; + readonly acquireLock: ( + request: { readonly version: string }, + ) => Promise; + readonly activateHomebrew: ( + prepared: UpdatePreparedHomebrew, + ) => Promise<{ readonly version: string; readonly executable: string }>; + readonly detectSource: () => Promise; + readonly now: () => Date; + readonly pid: number; +} + +export interface ActivatePendingUpdateOptions { + readonly enabled: boolean; + readonly automaticEnabled: boolean; + readonly deps?: Partial; +} + +function resolveDeps(overrides: Partial = {}): ActivatePendingUpdateDeps { + return { + readState: overrides.readState ?? (() => readUpdateInstallState()), + writeState: overrides.writeState ?? ((state) => writeUpdateInstallState(state)), + acquireLock: overrides.acquireLock ?? ((request) => tryAcquireUpdateInstallLock(request)), + activateHomebrew: + overrides.activateHomebrew ?? + ((prepared) => activateHomebrewUpdate(prepared, { logFile: getUpdateInstallLogFile() })), + detectSource: overrides.detectSource ?? (() => detectInstallSource()), + now: overrides.now ?? (() => new Date()), + pid: overrides.pid ?? process.pid, + }; +} + +function activationAttempts(state: UpdateInstallState, version: string): number { + const failure = state.lastFailure; + return failure?.version === version && failure.operation === 'activate' ? failure.attempts : 0; +} + +function isRunningPreparedVersion(currentVersion: string, preparedVersion: string): boolean { + return ( + valid(currentVersion) !== null && + valid(preparedVersion) !== null && + gte(currentVersion, preparedVersion) + ); +} + +export async function activatePendingUpdate( + currentVersion: string, + options: ActivatePendingUpdateOptions, +) { + if (!options.enabled) return { status: 'none' as const }; + const deps = resolveDeps(options.deps); + let state = await deps.readState(); + const pending = state.pending; + if (pending === null) return { status: 'none' as const }; + if (pending.requestedBy === 'automatic' && !options.automaticEnabled) { + return { status: 'none' as const }; + } + + if (await deps.detectSource() !== pending.source) { + await deps.writeState({ ...state, active: null, pending: null }); + return { status: 'invalidated' as const, version: pending.version }; + } + + if (isRunningPreparedVersion(currentVersion, pending.version)) { + const installedAt = deps.now().toISOString(); + await deps.writeState({ + active: null, + pending: null, + lastFailure: null, + lastSuccess: { + version: currentVersion, + installedAt, + notifiedAt: null, + }, + }); + return { status: 'finalized' as const, version: currentVersion }; + } + + if (activationAttempts(state, pending.version) >= ACTIVATION_FAILURE_LIMIT) { + // Terminal: drop the pending record (keeping lastFailure for preflight) + // so later launches stop retrying and reporting an in-progress update. + await deps.writeState({ ...state, pending: null }); + return { + status: 'failed' as const, + version: pending.version, + message: `Automatic activation failed ${String(ACTIVATION_FAILURE_LIMIT)} times`, + }; + } + + const lock = await deps.acquireLock({ version: pending.version }); + if (lock === null) return { status: 'in-progress' as const, version: pending.version }; + + try { + state = await deps.readState(); + if (state.pending?.jobId !== pending.jobId) return { status: 'none' as const }; + const startedAt = deps.now().toISOString(); + const activatingState: UpdateInstallState = { + ...state, + active: { + version: pending.version, + source: pending.source, + operation: 'activate', + jobId: pending.jobId, + startedAt, + pid: deps.pid, + }, + }; + await deps.writeState(activatingState); + + try { + const activated = await deps.activateHomebrew(pending); + await deps.writeState({ + ...activatingState, + active: null, + lastFailure: null, + }); + return { + status: 'activated' as const, + version: activated.version, + executable: activated.executable, + }; + } catch (error) { + const message = formatErrorMessage(error); + if (error instanceof PreparedHomebrewUpdateInvalidError) { + // Carry the cumulative prepare-failure count so repeated invalid + // artifacts can reach the auto-install failure threshold. + const priorFailure = activatingState.lastFailure; + const prepareAttempts = + priorFailure?.version === pending.version && priorFailure.operation === 'prepare' + ? priorFailure.attempts + 1 + : 1; + await deps.writeState({ + ...activatingState, + active: null, + pending: null, + lastFailure: { + version: pending.version, + failedAt: deps.now().toISOString(), + attempts: prepareAttempts, + operation: 'prepare', + message, + }, + }); + return { status: 'invalidated' as const, version: pending.version }; + } + const attempts = activationAttempts(activatingState, pending.version) + 1; + await deps.writeState({ + ...activatingState, + active: null, + lastFailure: { + version: pending.version, + failedAt: deps.now().toISOString(), + attempts, + operation: 'activate', + message, + }, + }); + return { status: 'failed' as const, version: pending.version, message }; + } + } finally { + await lock.release().catch(() => {}); + } +} diff --git a/apps/pythinker-code/src/cli/update/format-error.ts b/apps/pythinker-code/src/cli/update/format-error.ts new file mode 100644 index 00000000..9c36a53b --- /dev/null +++ b/apps/pythinker-code/src/cli/update/format-error.ts @@ -0,0 +1,4 @@ +/** Shared failure-message formatter for update install/prepare/activate state. */ +export function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/pythinker-code/src/cli/update/homebrew.ts b/apps/pythinker-code/src/cli/update/homebrew.ts new file mode 100644 index 00000000..866cc2c2 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/homebrew.ts @@ -0,0 +1,330 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants, createReadStream } from 'node:fs'; +import { access, mkdir, open, readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { valid } from 'semver'; +import { z } from 'zod'; + +import type { UpdatePreparedHomebrew } from './types'; + +const HOMEBREW_FORMULA = 'pythinker-code'; +const COMMAND_ERROR_TAIL_LENGTH = 2_000; + +const HomebrewInfoSchema = z.object({ + formulae: z.array(z.object({ + name: z.literal(HOMEBREW_FORMULA), + versions: z.object({ stable: z.string().min(1) }), + urls: z.object({ + stable: z.object({ + url: z.url(), + checksum: z.string().regex(/^[a-f0-9]{64}$/u), + }), + }), + linked_keg: z.string().nullable(), + pinned: z.boolean(), + })).length(1), +}); + +export interface HomebrewCommandOptions { + readonly capture?: boolean; + readonly inheritOutput?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly logFile?: string; +} + +export interface HomebrewCommandResult { + readonly stdout: string; + readonly stderr: string; +} + +export type HomebrewCommandRunner = ( + args: readonly string[], + options?: HomebrewCommandOptions, +) => Promise; + +export class PreparedHomebrewUpdateInvalidError extends Error {} + +interface HomebrewSnapshot { + readonly version: string; + readonly formulaUrl: string; + readonly artifactSha256: string; + readonly formulaFileSha256: string; + readonly artifactPath: string; + readonly linkedVersion: string | null; + readonly pinned: boolean; + readonly executable: string; +} + +export interface HomebrewUpdateDeps { + readonly run: HomebrewCommandRunner; + readonly hashFile: (filePath: string) => Promise; + readonly readFormula: (filePath: string) => Promise; + readonly ensureExecutable: (filePath: string) => Promise; + readonly now: () => Date; +} + +const NO_AUTO_UPDATE_ENV: NodeJS.ProcessEnv = { + HOMEBREW_NO_AUTO_UPDATE: '1', +}; + +const ACTIVATION_ENV: NodeJS.ProcessEnv = { + ...NO_AUTO_UPDATE_ENV, + HOMEBREW_NO_INSTALL_CLEANUP: '1', + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: '1', +}; + +function commandError( + command: string, + code: number | null, + signal: NodeJS.Signals | null, + stderr: string, +) { + const outcome = signal === null ? `code ${String(code)}` : `signal ${signal}`; + const detail = stderr.trim().slice(-COMMAND_ERROR_TAIL_LENGTH); + return new Error(`${command} exited with ${outcome}${detail === '' ? '' : `: ${detail}`}`); +} + +export async function runHomebrewCommand( + args: readonly string[], + options: HomebrewCommandOptions = {}, +): Promise { + const capture = options.capture ?? true; + const command = ['brew', ...args].join(' '); + const logPath = options.logFile; + const logFile = logPath === undefined + ? undefined + : await (async () => { + try { + await mkdir(dirname(logPath), { recursive: true }); + return await open(logPath, 'a', 0o600); + } catch { + return undefined; + } + })(); + let logWrites = Promise.resolve(); + const appendLog = (chunk: string | Uint8Array): void => { + if (logFile === undefined) return; + // Normalize to bytes: FileHandle.write has separate string/buffer + // overloads that reject the union type. + const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + logWrites = logWrites + .then(async () => { + await logFile.write(data); + }) + .catch(() => {}); + }; + appendLog(`\n[${new Date().toISOString()}] $ ${command}\n`); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + try { + await new Promise((resolve, reject) => { + const child = spawn('brew', [...args], { + cwd: homedir(), + env: { ...process.env, ...options.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (chunk: Buffer) => { + if (capture) stdout.push(chunk); + if (options.inheritOutput === true) process.stdout.write(chunk); + appendLog(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr.push(chunk); + if (options.inheritOutput === true) process.stderr.write(chunk); + appendLog(chunk); + }); + child.once('error', reject); + child.once('close', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject(commandError(command, code, signal, Buffer.concat(stderr).toString('utf-8'))); + }); + }); + } finally { + await logWrites; + await logFile?.close().catch(() => {}); + } + + return { + stdout: Buffer.concat(stdout).toString('utf-8'), + stderr: Buffer.concat(stderr).toString('utf-8'), + }; +} + +async function sha256File(filePath: string) { + const hash = createHash('sha256'); + await new Promise((resolve, reject) => { + const stream = createReadStream(filePath); + stream.on('data', (chunk) => { hash.update(chunk); }); + stream.once('error', reject); + stream.once('end', resolve); + }); + return hash.digest('hex'); +} + +function resolveDeps(overrides: Partial = {}): HomebrewUpdateDeps { + return { + run: overrides.run ?? runHomebrewCommand, + hashFile: overrides.hashFile ?? sha256File, + readFormula: overrides.readFormula ?? ((filePath) => readFile(filePath, 'utf-8')), + ensureExecutable: + overrides.ensureExecutable ?? ((filePath) => access(filePath, fsConstants.X_OK)), + now: overrides.now ?? (() => new Date()), + }; +} + +async function inspectHomebrewFormula( + deps: HomebrewUpdateDeps, + logFile: string | undefined, +): Promise { + const commandOptions: HomebrewCommandOptions = { + env: NO_AUTO_UPDATE_ENV, + logFile, + }; + const info = HomebrewInfoSchema.parse(JSON.parse( + (await deps.run(['info', '--json=v2', HOMEBREW_FORMULA], commandOptions)).stdout, + )); + const formula = info.formulae[0]; + if (formula === undefined) throw new Error('Homebrew formula metadata is missing'); + if (valid(formula.versions.stable) === null) { + throw new Error(`Homebrew returned an invalid version: ${formula.versions.stable}`); + } + + const formulaPath = (await deps.run(['formula', HOMEBREW_FORMULA], commandOptions)).stdout.trim(); + const artifactPath = ( + await deps.run( + ['--cache', '--build-from-source', '--formula', HOMEBREW_FORMULA], + commandOptions, + ) + ).stdout.trim(); + const prefix = (await deps.run(['--prefix', HOMEBREW_FORMULA], commandOptions)).stdout.trim(); + if (formulaPath === '' || artifactPath === '' || prefix === '') { + throw new Error('Homebrew returned an empty update path'); + } + + return { + version: formula.versions.stable, + formulaUrl: formula.urls.stable.url, + artifactSha256: formula.urls.stable.checksum, + formulaFileSha256: createHash('sha256') + .update(await deps.readFormula(formulaPath), 'utf-8') + .digest('hex'), + artifactPath, + linkedVersion: formula.linked_keg, + pinned: formula.pinned, + executable: join(prefix, 'bin', 'pythinker'), + }; +} + +function assertSamePreparedFormula( + prepared: UpdatePreparedHomebrew, + snapshot: HomebrewSnapshot, +): void { + if ( + snapshot.version !== prepared.version || + snapshot.formulaUrl !== prepared.formulaUrl || + snapshot.artifactSha256 !== prepared.artifactSha256 || + snapshot.formulaFileSha256 !== prepared.formulaFileSha256 || + snapshot.artifactPath !== prepared.artifactPath + ) { + throw new PreparedHomebrewUpdateInvalidError( + 'Homebrew formula changed after the update was prepared', + ); + } +} + +async function verifyPreparedArtifact( + prepared: UpdatePreparedHomebrew, + deps: HomebrewUpdateDeps, +): Promise { + const actual = await deps.hashFile(prepared.artifactPath); + if (actual !== prepared.artifactSha256) { + throw new PreparedHomebrewUpdateInvalidError( + 'Prepared Homebrew artifact failed SHA-256 verification', + ); + } +} + +export interface PrepareHomebrewUpdateRequest { + readonly jobId: string; + readonly requestedVersion: string; + readonly requestedBy: UpdatePreparedHomebrew['requestedBy']; +} + +export async function prepareHomebrewUpdate( + request: PrepareHomebrewUpdateRequest, + options: { readonly logFile?: string; readonly deps?: Partial } = {}, +): Promise { + if (valid(request.requestedVersion) === null) { + throw new Error(`Invalid requested update version: ${request.requestedVersion}`); + } + const deps = resolveDeps(options.deps); + await deps.run(['update'], { capture: false, logFile: options.logFile }); + + const before = await inspectHomebrewFormula(deps, options.logFile); + if (before.pinned) throw new Error('The Homebrew formula is pinned'); + if (before.version !== request.requestedVersion) { + throw new PreparedHomebrewUpdateInvalidError( + `Homebrew formula ${before.version} does not match requested update ${request.requestedVersion}`, + ); + } + + await deps.run( + ['fetch', '--build-from-source', '--retry', '--formula', HOMEBREW_FORMULA], + { capture: false, env: NO_AUTO_UPDATE_ENV, logFile: options.logFile }, + ); + const after = await inspectHomebrewFormula(deps, options.logFile); + const prepared: UpdatePreparedHomebrew = { + jobId: request.jobId, + source: 'homebrew', + version: before.version, + preparedAt: deps.now().toISOString(), + requestedBy: request.requestedBy, + formulaUrl: before.formulaUrl, + artifactKind: 'source', + artifactSha256: before.artifactSha256, + formulaFileSha256: before.formulaFileSha256, + artifactPath: before.artifactPath, + }; + assertSamePreparedFormula(prepared, after); + await verifyPreparedArtifact(prepared, deps); + return prepared; +} + +export async function activateHomebrewUpdate( + prepared: UpdatePreparedHomebrew, + options: { readonly logFile?: string; readonly deps?: Partial } = {}, +) { + const deps = resolveDeps(options.deps); + const before = await inspectHomebrewFormula(deps, options.logFile); + assertSamePreparedFormula(prepared, before); + await verifyPreparedArtifact(prepared, deps); + + if (before.linkedVersion !== prepared.version) { + await deps.run( + ['upgrade', '--formula', '--build-from-source', '--no-ask', HOMEBREW_FORMULA], + { + capture: false, + inheritOutput: true, + env: ACTIVATION_ENV, + logFile: options.logFile, + }, + ); + } + + const after = await inspectHomebrewFormula(deps, options.logFile); + assertSamePreparedFormula(prepared, after); + if (after.linkedVersion !== prepared.version) { + throw new Error( + `Homebrew linked ${after.linkedVersion ?? 'no version'} instead of ${prepared.version}`, + ); + } + await deps.ensureExecutable(after.executable); + return { version: prepared.version, executable: after.executable }; +} diff --git a/apps/pythinker-code/src/cli/update/install-lock.ts b/apps/pythinker-code/src/cli/update/install-lock.ts index 7bcc2408..3242587d 100644 --- a/apps/pythinker-code/src/cli/update/install-lock.ts +++ b/apps/pythinker-code/src/cli/update/install-lock.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { link, mkdir, open, readFile, unlink } from 'node:fs/promises'; import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; @@ -124,16 +124,26 @@ async function createLockFile( ): Promise { const now = request.now ?? new Date(); const ownerId = randomUUID(); - const file = await open(filePath, 'wx', 0o600); + const stagedPath = `${filePath}.${ownerId}.tmp`; + const file = await open(stagedPath, 'wx', 0o600); try { - await file.writeFile(`${JSON.stringify({ - version: request.version, - ownerId, - pid: process.pid, - startedAt: now.toISOString(), - }, null, 2)}\n`, 'utf-8'); + try { + await file.writeFile(`${JSON.stringify({ + version: request.version, + ownerId, + pid: process.pid, + startedAt: now.toISOString(), + }, null, 2)}\n`, 'utf-8'); + await file.sync(); + } finally { + await file.close(); + } + // Publish a fully-written record atomically. Creating the destination with + // open('wx') and filling it afterward lets a concurrent reader mistake the + // transient empty file for a stale lock and unlink a live owner's lease. + await link(stagedPath, filePath); } finally { - await file.close(); + await unlink(stagedPath).catch(() => {}); } let released = false; diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index bde738a3..c34ce297 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -15,6 +15,9 @@ const InstallSourceSchema: z.ZodType = z.enum([ 'unsupported', ]); +const UpdateInstallOperationSchema = z.enum(['install', 'prepare', 'activate']); +const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); + const UpdateInstallStateSchema: z.ZodType = z .object({ active: z @@ -23,14 +26,34 @@ const UpdateInstallStateSchema: z.ZodType = z source: InstallSourceSchema, startedAt: z.string().min(1), pid: z.number().int().positive().optional(), + operation: UpdateInstallOperationSchema.optional(), + jobId: z.uuid().optional(), }) .strict() .nullable(), + pending: z + .object({ + jobId: z.uuid(), + source: z.literal('homebrew'), + version: z.string().min(1), + preparedAt: z.string().min(1), + requestedBy: z.enum(['automatic', 'manual']), + formulaUrl: z.url(), + artifactKind: z.literal('source'), + artifactSha256: Sha256Schema, + formulaFileSha256: Sha256Schema, + artifactPath: z.string().min(1), + }) + .strict() + .nullable() + .default(null), lastFailure: z .object({ version: z.string().min(1), failedAt: z.string().min(1), attempts: z.number().int().min(1), + operation: UpdateInstallOperationSchema.optional(), + message: z.string().min(1).optional(), }) .strict() .nullable(), @@ -61,5 +84,5 @@ export async function writeUpdateInstallState( value: UpdateInstallState, filePath: string = getUpdateInstallStateFile(), ): Promise { - await writeJsonFile(filePath, UpdateInstallStateSchema, value); + await writeJsonFile(filePath, UpdateInstallStateSchema, value, { durable: true }); } diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index e9d467e8..465760e2 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -1,4 +1,8 @@ import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; + +import { gte, valid } from 'semver'; import { log, type Logger } from '@pythoughts/pythinker-code-sdk'; import type { TelemetryProperties } from '@pythoughts/pythinker-telemetry'; @@ -10,6 +14,7 @@ import { import { loadTuiConfig } from '#/tui/config'; import { readUpdateCache } from './cache'; +import { formatErrorMessage } from './format-error'; import { tryAcquireUpdateInstallLock } from './install-lock'; import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state'; import { @@ -34,10 +39,12 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, + type UpdateInstallOperation, type UpdateInstallState, type UpdateCache, type UpdateManifest, type UpdatePreflightResult, + type UpdateRequestOrigin, type UpdateTarget, } from './types'; @@ -55,6 +62,7 @@ const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2; const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000; const AUTO_INSTALL_ACTIVE_CLOCK_SKEW_MS = 5 * 60 * 1000; const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000; +const UPDATE_HELPER_ENV = 'PYTHINKER_CODE_UPDATE_HELPER'; type UpdateLogger = Pick; @@ -89,6 +97,8 @@ export function installCommandFor( } } +export type AutomaticUpdateMode = 'background-install' | 'restart-install' | 'manual'; + export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': @@ -97,8 +107,8 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform) case 'bun-global': return true; case 'homebrew': - // Homebrew upgrade may mutate other dependents and the formula can lag - // behind the CDN release — prompt the user to run `brew upgrade` manually. + // Foreground installUpdate() never owns Homebrew. Passive and explicit + // TUI updates use the separate prepare-on-restart lifecycle instead. return false; case 'native': return platform !== 'win32'; @@ -107,6 +117,14 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform) } } +export function automaticUpdateModeFor( + source: InstallSource, + platform: NodeJS.Platform, +): AutomaticUpdateMode { + if (source === 'homebrew') return 'restart-install'; + return canAutoInstall(source, platform) ? 'background-install' : 'manual'; +} + interface SpawnCommand { readonly cmd: string; readonly args: readonly string[]; @@ -140,10 +158,6 @@ export function spawnForSource( } } -function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export function renderManualUpdateMessage( currentVersion: string, target: UpdateTarget, @@ -170,8 +184,7 @@ export function renderManualUpdateMessage( } const homebrewHint = source === 'homebrew' - ? `Homebrew installs do not auto-update. For automatic background updates, ` + - `switch to the native installer: ${NATIVE_INSTALL_COMMAND_UNIX}\n` + ? 'Automatic Homebrew preparation is disabled or could not complete.\n' : ''; return ( `A newer version of ${NPM_PACKAGE_NAME} is available ` + @@ -336,8 +349,25 @@ function nowIso(): string { return new Date().toISOString(); } -function failureAttemptsFor(state: UpdateInstallState, target: UpdateTarget): number { - return state.lastFailure?.version === target.version ? state.lastFailure.attempts : 0; +function failureAttemptsFor( + state: UpdateInstallState, + target: UpdateTarget, + operation?: UpdateInstallOperation, +): number { + const failure = state.lastFailure; + if (failure?.version !== target.version) return 0; + // Threshold gates omit `operation`: any failure kind at the limit parks the + // version. Increment sites pass their operation so a counter never resumes + // from another operation's attempts. Legacy records without `operation` + // count toward any operation. + if ( + operation !== undefined && + failure.operation !== undefined && + failure.operation !== operation + ) { + return 0; + } + return failure.attempts; } function isProcessRunning(pid: number): boolean { @@ -537,6 +567,126 @@ export async function installUpdate( }); } +async function waitForChildSpawn(child: ReturnType): Promise { + await new Promise((resolve, reject) => { + const onSpawn = (): void => { + child.off('error', onError); + child.on('error', () => {}); + resolve(); + }; + const onError = (error: Error): void => { + child.off('spawn', onSpawn); + reject(error); + }; + child.once('spawn', onSpawn); + child.once('error', onError); + }); +} + +function updateHelperCommand( + operation: 'prepare-homebrew', + jobId: string, + version: string, + requestedBy: UpdateRequestOrigin, +): SpawnCommand { + const launcherPath = process.argv[1]; + if (launcherPath === undefined) throw new Error('cannot locate the Pythinker Code launcher'); + return { + cmd: process.execPath, + args: [launcherPath, '__update_helper', operation, jobId, version, requestedBy], + }; +} + +function preparedVersionCoversTarget(preparedVersion: string, targetVersion: string): boolean { + return valid(preparedVersion) !== null && valid(targetVersion) !== null && gte(preparedVersion, targetVersion); +} + +async function startBackgroundHomebrewPreparation( + state: UpdateInstallState, + currentVersion: string, + target: UpdateTarget, + requestedBy: UpdateRequestOrigin, + track: RunUpdatePreflightOptions['track'], + logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, +): Promise { + const lock = await tryAcquireUpdateInstallLock({ version: target.version }); + if (lock === null) return false; + + try { + const freshState = await readUpdateInstallState().catch(() => state); + if ( + hasFreshActiveInstall(freshState) || + (freshState.pending !== null && preparedVersionCoversTarget(freshState.pending.version, target.version)) || + failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD + ) { + return false; + } + + const jobId = randomUUID(); + // A retained older verified `pending` stays installable if this newer + // preparation fails; the helper's success path replaces it. + const startedState: UpdateInstallState = { + ...freshState, + active: { + version: target.version, + source: 'homebrew', + operation: 'prepare', + jobId, + startedAt: nowIso(), + }, + }; + await writeUpdateInstallState(startedState); + + try { + const { cmd, args } = updateHelperCommand( + 'prepare-homebrew', + jobId, + target.version, + requestedBy, + ); + const child = spawn(cmd, [...args], { + cwd: homedir(), + detached: true, + env: { ...process.env, [UPDATE_HELPER_ENV]: '1' }, + stdio: 'ignore', + }); + await waitForChildSpawn(child); + child.unref(); + } catch (error) { + const attempts = failureAttemptsFor(startedState, target, 'prepare') + 1; + await writeUpdateInstallState({ + ...startedState, + active: null, + lastFailure: { + version: target.version, + failedAt: nowIso(), + attempts, + operation: 'prepare', + message: formatErrorMessage(error), + }, + }).catch(() => {}); + throw error; + } + + trackUpdateEvent(track, 'update_background_prepare_started', { + current_version: currentVersion, + target_version: target.version, + source: 'homebrew', + ...rolloutTelemetry, + }); + logUpdateInfo(logger, 'background update preparation started', { + currentVersion, + targetVersion: target.version, + source: 'homebrew', + jobId, + }); + return true; + } finally { + await lock.release().catch(() => {}); + } +} + async function startBackgroundInstall( state: UpdateInstallState, currentVersion: string, @@ -546,9 +696,9 @@ async function startBackgroundInstall( track: RunUpdatePreflightOptions['track'], logger: UpdateLogger, rolloutTelemetry: RolloutTelemetry, -): Promise { +): Promise { const lock = await tryAcquireUpdateInstallLock({ version: target.version }); - if (lock === null) return; + if (lock === null) return false; let finalizerOwnsLock = false; try { @@ -557,7 +707,7 @@ async function startBackgroundInstall( hasFreshActiveInstall(freshState) || failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD ) { - return; + return false; } let startedState: UpdateInstallState = { @@ -595,7 +745,7 @@ async function startBackgroundInstall( } if (settled) return; settled = true; - const attempts = failureAttemptsFor(startedState, target) + 1; + const attempts = failureAttemptsFor(startedState, target, 'install') + 1; const nextState: UpdateInstallState = succeeded ? { @@ -615,6 +765,7 @@ async function startBackgroundInstall( version: target.version, failedAt: nowIso(), attempts, + operation: 'install', }, }; try { @@ -670,6 +821,7 @@ async function startBackgroundInstall( finalizerOwnsLock = true; ready = true; if (pendingOutcome !== undefined) void finish(pendingOutcome); + return true; // When startup failed before handoff, release the lock here; the // finalizer releases it once the terminal state write completes. } finally { @@ -689,13 +841,40 @@ async function tryStartAutomaticBackgroundInstall( logger: UpdateLogger, rolloutTelemetry: RolloutTelemetry, ): Promise { - const sourceCanAutoInstall = canAutoInstall(source, platform); - const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false; - if (!autoInstallUpdates || !sourceCanAutoInstall) return false; + const autoInstallUpdates = await shouldAutoInstallUpdates(); + if (!autoInstallUpdates) return false; if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { return false; } if (hasFreshActiveInstall(installState)) return true; + + if (source === 'homebrew') { + if ( + installState.pending !== null && + preparedVersionCoversTarget(installState.pending.version, target.version) + ) return true; + try { + await startBackgroundHomebrewPreparation( + installState, + currentVersion, + target, + 'automatic', + track, + logger, + rolloutTelemetry, + ); + return true; + } catch (error) { + logUpdateWarn(logger, 'background update preparation could not start', { + targetVersion: target.version, + source, + error: formatErrorMessage(error), + }); + return false; + } + } + + if (!canAutoInstall(source, platform)) return false; try { await startBackgroundInstall( installState, @@ -721,8 +900,13 @@ async function tryStartAutomaticBackgroundInstall( export type ManualUpdateResult = | { readonly status: 'up-to-date' } | { readonly status: 'check-failed'; readonly message: string } - | { readonly status: 'started'; readonly version: string } - | { readonly status: 'in-progress'; readonly version: string } + | { readonly status: 'started'; readonly version: string; readonly installOnRestart: boolean } + | { + readonly status: 'in-progress'; + readonly version: string; + readonly installOnRestart: boolean; + readonly readyToInstall: boolean; + } | { readonly status: 'manual'; readonly version: string; @@ -733,8 +917,8 @@ export type ManualUpdateResult = /** * Explicit user-requested update (TUI `/update`). Unlike the passive * preflight it ignores the rollout delay and the `auto_install` preference — - * the user asked, so we install — but still reuses the background installer, - * its lock, and its failure bookkeeping. The env kill-switch is also ignored: + * the user asked, so we install or prepare the Homebrew update — while reusing + * the background lifecycle, lock, and failure bookkeeping. The env kill-switch is also ignored: * it gates automatic behavior, not explicit requests (matching `pythinker upgrade`). */ export async function startManualUpdate( @@ -752,24 +936,40 @@ export async function startManualUpdate( const platform = process.platform; const source = await detectInstallSource().catch(() => 'unsupported' as const); - if (!canAutoInstall(source, platform)) { - return { - status: 'manual', - version: target.version, - command: installCommandFor(source, target.version, platform), - source, - }; - } - const installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); if (hasFreshActiveInstall(installState)) { return { status: 'in-progress', version: installState.active?.version ?? target.version, + installOnRestart: installState.active?.source === 'homebrew', + readyToInstall: false, + }; + } + if ( + source === 'homebrew' && + installState.pending !== null && + preparedVersionCoversTarget(installState.pending.version, target.version) + ) { + const pending = installState.pending; + if (pending.requestedBy === 'automatic') { + try { + await writeUpdateInstallState({ + ...installState, + pending: { ...pending, requestedBy: 'manual' }, + }); + } catch (error) { + return { status: 'check-failed', message: formatErrorMessage(error) }; + } + } + return { + status: 'in-progress', + version: pending.version, + installOnRestart: true, + readyToInstall: true, }; } // Repeated background failures fall back to the copyable command instead of - // claiming "started" for an install startBackgroundInstall would refuse. + // claiming "started" for work the background lifecycle would refuse. if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { return { status: 'manual', @@ -780,7 +980,43 @@ export async function startManualUpdate( } try { - await startBackgroundInstall( + const rolloutTelemetry = rolloutTelemetryFor( + resolveUpdateDeviceId(), + target.version, + cache.manifest, + true, + ); + if (source === 'homebrew') { + const started = await startBackgroundHomebrewPreparation( + installState, + currentVersion, + target, + 'manual', + undefined, + logger, + rolloutTelemetry, + ); + // Another process holds the lock or the under-lock re-check refused: + // nothing new was started, so don't claim it was. + if (!started) { + return { + status: 'in-progress', + version: target.version, + installOnRestart: true, + readyToInstall: false, + }; + } + return { status: 'started', version: target.version, installOnRestart: true }; + } + if (!canAutoInstall(source, platform)) { + return { + status: 'manual', + version: target.version, + command: installCommandFor(source, target.version, platform), + source, + }; + } + const started = await startBackgroundInstall( installState, currentVersion, target, @@ -788,9 +1024,17 @@ export async function startManualUpdate( platform, undefined, logger, - rolloutTelemetryFor(resolveUpdateDeviceId(), target.version, cache.manifest, true), + rolloutTelemetry, ); - return { status: 'started', version: target.version }; + if (!started) { + return { + status: 'in-progress', + version: target.version, + installOnRestart: false, + readyToInstall: false, + }; + } + return { status: 'started', version: target.version, installOnRestart: false }; } catch (error) { return { status: 'check-failed', message: formatErrorMessage(error) }; } diff --git a/apps/pythinker-code/src/cli/update/types.ts b/apps/pythinker-code/src/cli/update/types.ts index f7691e9b..485535ec 100644 --- a/apps/pythinker-code/src/cli/update/types.ts +++ b/apps/pythinker-code/src/cli/update/types.ts @@ -40,18 +40,38 @@ export interface UpdateCache { readonly manifest: UpdateManifest | null; } +export type UpdateInstallOperation = 'install' | 'prepare' | 'activate'; +export type UpdateRequestOrigin = 'automatic' | 'manual'; + export interface UpdateInstallActive { readonly version: string; readonly source: InstallSource; /** Installer process id; absent in records persisted by older versions. */ readonly startedAt: string; readonly pid?: number; + readonly operation?: UpdateInstallOperation; + readonly jobId?: string; +} + +export interface UpdatePreparedHomebrew { + readonly jobId: string; + readonly source: 'homebrew'; + readonly version: string; + readonly preparedAt: string; + readonly requestedBy: UpdateRequestOrigin; + readonly formulaUrl: string; + readonly artifactKind: 'source'; + readonly artifactSha256: string; + readonly formulaFileSha256: string; + readonly artifactPath: string; } export interface UpdateInstallFailure { readonly version: string; readonly failedAt: string; readonly attempts: number; + readonly operation?: UpdateInstallOperation; + readonly message?: string; } export interface UpdateInstallSuccess { @@ -62,6 +82,7 @@ export interface UpdateInstallSuccess { export interface UpdateInstallState { readonly active: UpdateInstallActive | null; + readonly pending: UpdatePreparedHomebrew | null; readonly lastFailure: UpdateInstallFailure | null; readonly lastSuccess: UpdateInstallSuccess | null; } @@ -81,6 +102,7 @@ export function emptyUpdateCache(): UpdateCache { export function emptyUpdateInstallState(): UpdateInstallState { return { active: null, + pending: null, lastFailure: null, lastSuccess: null, }; diff --git a/apps/pythinker-code/src/cli/update/update-helper.ts b/apps/pythinker-code/src/cli/update/update-helper.ts new file mode 100644 index 00000000..a373972c --- /dev/null +++ b/apps/pythinker-code/src/cli/update/update-helper.ts @@ -0,0 +1,147 @@ +import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { valid } from 'semver'; +import { z } from 'zod'; + +import { getUpdateInstallLogFile } from '#/utils/paths'; + +import { formatErrorMessage } from './format-error'; +import { prepareHomebrewUpdate } from './homebrew'; +import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import type { UpdateInstallActive, UpdateInstallState } from './types'; + +const UPDATE_INSTALL_LOG_MAX_BYTES = 1024 * 1024; + +const PrepareHomebrewArgsSchema = z.tuple([ + z.literal('prepare-homebrew'), + z.uuid(), + z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), + z.enum(['automatic', 'manual']), +]); + +async function rotateHelperLogIfNeeded(): Promise { + const filePath = getUpdateInstallLogFile(); + try { + await mkdir(dirname(filePath), { recursive: true }); + const size = await stat(filePath).then((entry) => entry.size, () => 0); + if (size >= UPDATE_INSTALL_LOG_MAX_BYTES) { + await writeFile(filePath, '', { encoding: 'utf-8', mode: 0o600 }); + } + } catch { + // Diagnostics must not change the update outcome. + } +} + +async function appendHelperLog(message: string): Promise { + const filePath = getUpdateInstallLogFile(); + try { + await mkdir(dirname(filePath), { recursive: true }); + const line = `[${new Date().toISOString()}] ${message}\n`; + await appendFile(filePath, line, { encoding: 'utf-8', mode: 0o600 }); + } catch { + // Diagnostics must not change the update outcome. + } +} + +function prepareFailureAttempts(state: UpdateInstallState, version: string): number { + const failure = state.lastFailure; + return failure?.version === version && failure.operation === 'prepare' ? failure.attempts : 0; +} + +function ownsPrepareJob( + state: UpdateInstallState, + jobId: string, + requestedVersion: string, +): state is UpdateInstallState & { readonly active: UpdateInstallActive } { + const active = state.active; + return ( + active?.jobId === jobId && + active.operation === 'prepare' && + active.source === 'homebrew' && + active.version === requestedVersion + ); +} + +export function dispatchUpdateHelperIfRequested(): boolean { + if (process.env['PYTHINKER_CODE_UPDATE_HELPER'] !== '1') return false; + const commandIndex = process.argv[2] === '__update_helper' + ? 2 + : process.argv[1] === '__update_helper' + ? 1 + : -1; + if (commandIndex < 0) return false; + void runUpdateHelper(process.argv.slice(commandIndex + 1)) + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write(`Update helper failed: ${formatErrorMessage(error)}\n`); + process.exitCode = 1; + }); + return true; +} + +export async function runUpdateHelper(args: readonly string[]): Promise { + const parsed = PrepareHomebrewArgsSchema.safeParse(args); + if (!parsed.success) { + await appendHelperLog('update helper rejected invalid arguments'); + return 2; + } + const [, jobId, requestedVersion, requestedBy] = parsed.data; + await rotateHelperLogIfNeeded(); + let state = await readUpdateInstallState(); + if (!ownsPrepareJob(state, jobId, requestedVersion)) { + await appendHelperLog(`prepare job ${jobId} is no longer active`); + return 0; + } + + try { + state = { + ...state, + active: { + ...state.active, + pid: process.pid, + }, + }; + await writeUpdateInstallState(state); + await appendHelperLog(`prepare job ${jobId} started for ${requestedVersion}`); + + const prepared = await prepareHomebrewUpdate( + { jobId, requestedVersion, requestedBy }, + { logFile: getUpdateInstallLogFile() }, + ); + const latest = await readUpdateInstallState(); + if (!ownsPrepareJob(latest, jobId, requestedVersion)) { + await appendHelperLog(`prepare job ${jobId} lost ownership before completion`); + return 0; + } + // Keep `lastFailure` so prepare attempts accumulate when a "successful" + // preparation later turns out invalid at activation; a fully activated + // update clears it in `activatePendingUpdate`. + await writeUpdateInstallState({ + ...latest, + active: null, + pending: prepared, + }); + await appendHelperLog(`prepare job ${jobId} verified ${prepared.version}`); + return 0; + } catch (error) { + const latest = await readUpdateInstallState(); + if (!ownsPrepareJob(latest, jobId, requestedVersion)) return 1; + const message = formatErrorMessage(error); + await writeUpdateInstallState({ + ...latest, + active: null, + lastFailure: { + version: requestedVersion, + failedAt: new Date().toISOString(), + attempts: prepareFailureAttempts(latest, requestedVersion) + 1, + operation: 'prepare', + message, + }, + }).catch(() => {}); + await appendHelperLog(`prepare job ${jobId} failed: ${message}`); + return 1; + } +} diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index a5925214..89a88ecb 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -27,6 +27,7 @@ export const PYTHINKER_CODE_BIN_DIR_NAME = 'bin'; export const PYTHINKER_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const PYTHINKER_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const PYTHINKER_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; +export const PYTHINKER_CODE_UPDATE_INSTALL_LOG_FILE_NAME = 'install.log'; export const PYTHINKER_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; export const PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; diff --git a/apps/pythinker-code/src/main.ts b/apps/pythinker-code/src/main.ts index 3023ba88..749b27f4 100644 --- a/apps/pythinker-code/src/main.ts +++ b/apps/pythinker-code/src/main.ts @@ -32,7 +32,13 @@ import { formatStartupError } from './cli/startup-error'; import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; import { handleUpgrade } from './cli/sub/upgrade'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; -import { runUpdatePreflight } from './cli/update/preflight'; +import { activatePendingUpdate } from './cli/update/activation'; +import { + isAutoUpdateDisabledByEnv, + runUpdatePreflight, + shouldAutoInstallUpdates, +} from './cli/update/preflight'; +import { dispatchUpdateHelperIfRequested } from './cli/update/update-helper'; import { createPythinkerCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; @@ -51,6 +57,36 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom throw error; } + const interactiveShell = + validated.uiMode === 'shell' && + validated.options.initOnly !== true && + process.stdin.isTTY && + process.stdout.isTTY; + const activation = await activatePendingUpdate(version, { + enabled: interactiveShell, + automaticEnabled: + interactiveShell && + !isAutoUpdateDisabledByEnv() && + await shouldAutoInstallUpdates(), + }).catch(async (error: unknown) => { + await writeAndDrain( + process.stderr, + `warning: unable to process a pending Pythinker Code update: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ).catch(() => {}); + return { status: 'none' as const }; + }); + if (activation.status === 'failed') { + await writeAndDrain( + process.stderr, + `warning: failed to activate Pythinker Code ${activation.version}: ${activation.message}\n`, + ); + } else if (activation.status === 'activated') { + await Promise.all([drainWritable(process.stdout), drainWritable(process.stderr)]); + relaunchUpdatedCli(activation.executable); + } + const preflightResult = await runUpdatePreflight( version, validated.uiMode === 'print' || validated.options.initOnly === true @@ -135,6 +171,7 @@ export function main(): void { // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); installNativeModuleHook(); + if (dispatchUpdateHelperIfRequested()) return; if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. @@ -219,6 +256,18 @@ if (process.env['PYTHINKER_CODE_OPENTUI_SMOKE'] === '1') { main(); } +function relaunchUpdatedCli(executable: string): never { + if (process.execve === undefined) { + throw new Error('process.execve is unavailable for update relaunch'); + } + process.execve( + executable, + [executable, ...process.argv.slice(2)], + process.env, + ); + throw new Error('update relaunch returned unexpectedly'); +} + async function logStartupFailure(operation: string, error: unknown): Promise { log.error('startup failed', { operation, error }); try { diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index ab95d375..d8f6e0e3 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -10,7 +10,7 @@ import type { import { handleDoctor } from '#/cli/sub/doctor'; import { startManualUpdate } from '#/cli/update/preflight'; -import { NATIVE_INSTALL_COMMAND_UNIX, PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app'; +import { PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app'; import { openUrl } from '#/utils/open-url'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; @@ -313,22 +313,25 @@ export async function handleUpdateCommand( case 'started': host.showNotice( `Updating to v${result.version}`, - 'Installing in the background — restart the CLI when it completes.', + result.installOnRestart + ? 'Preparing with Homebrew in the background. Once ready, restart the CLI to install it.' + : 'Installing in the background — restart the CLI when it completes.', ); return; case 'in-progress': host.showNotice( `Update to v${result.version} already in progress`, - 'Restart the CLI once it completes.', + result.installOnRestart + ? result.readyToInstall + ? 'Restart the CLI to install it.' + : 'Restart after the current update operation finishes.' + : 'Restart the CLI once it completes.', ); return; case 'manual': host.showNotice( `Update available — v${result.version}`, - result.source === 'homebrew' - ? `Homebrew installs do not auto-update. Run: ${result.command}\n` + - `For automatic background updates, switch to the native installer: ${NATIVE_INSTALL_COMMAND_UNIX}` - : `Run: ${result.command}`, + `Run: ${result.command}`, ); return; case 'check-failed': diff --git a/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts index 35055e08..40b823e6 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts @@ -4,7 +4,7 @@ const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ { value: 'on', label: 'On', - description: 'Install new versions in the background.', + description: 'Update automatically in the background.', }, { value: 'off', diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index ddf4b55c..84982bb7 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -9,7 +9,12 @@ import { shimmerText } from '#/tui/utils/shimmer'; const RESUMED_ITEM_LABEL = '(resumed)'; const ORCHESTRATING_LABEL = 'Orchestrating'; -const ORCHESTRATING_LABEL_WIDTH = visibleWidth(ORCHESTRATING_LABEL); +const FINALIZING_LABEL = 'Finalizing'; +// Pad to the wider live label so the suffix column never shifts between them. +const LIVE_LABEL_WIDTH = Math.max( + visibleWidth(ORCHESTRATING_LABEL), + visibleWidth(FINALIZING_LABEL), +); const MAX_DYNAMIC_WORKFLOW_MEMBERS = 128; /** Lifecycle state of one delegated agent row, driven only by observed events. */ @@ -41,7 +46,9 @@ export interface DynamicWorkflowMember { endedAtMs?: number; /** * Observed-stage progress heuristic (0-100): the protocol emits no per-task - * percentage, so stages step through fixed values and never predict time. + * percentage, so stage floors map to observed events and streamed deltas + * creep asymptotically toward a ceiling. May hold fractional values + * internally; display floors it. Only a terminal event reaches 100. */ progressPercent: number; } @@ -249,12 +256,27 @@ export class DynamicWorkflowMissionControlComponent implements Component { if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return; this.markStarted(input.agentId); const recordActivity = input.delta.includes('\n') || member.latest.length === 0; - // Text after a tool call counts as finalizing; earlier text is mid-work output. + // Text after a tool call counts as finalizing; earlier text is mid-work + // output. Each delta creeps toward the stage ceiling — with a minimum + // step so long streams keep visibly moving — without claiming completion. + const percent = member.progressPercent; + const { + toolActivityProgress, + finalizingCreepCeiling, + modelActivityProgress, + midworkCreepCeiling, + progressCreepRate, + progressCreepMinStep, + } = DYNAMIC_WORKFLOW_RENDERING; + const creepToward = (ceiling: number): number => Math.min( + ceiling, + percent + Math.max(progressCreepMinStep, (ceiling - percent) * progressCreepRate), + ); this.advanceMemberProgress( member, - member.progressPercent >= DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress - ? DYNAMIC_WORKFLOW_RENDERING.finalizingProgress - : DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress, + percent >= toolActivityProgress + ? creepToward(finalizingCreepCeiling) + : Math.max(modelActivityProgress, creepToward(midworkCreepCeiling)), ); const latest = latestNonEmptyLine(`${member.latest}${input.delta}`); this.setLatest(member, latest, recordActivity); @@ -465,11 +487,21 @@ export class DynamicWorkflowMissionControlComponent implements Component { const loader = terminal ? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase)) : this.activitySpinnerText?.() ?? currentTheme.fg('primary', '●'); + const aggregateMembers = this.aggregateMembers(); + // All spawned agents are done but the tool result has not arrived yet: + // the label says so instead of pretending orchestration is still active. + // Every member counts — including out-of-band rows beyond knownTotal — + // so the label never claims "done" above a row still marked running. + const finalizing = !terminal && + this.model.knownTotal !== undefined && + this.model.knownTotal > 0 && + aggregateMembers.length === this.model.knownTotal && + this.model.members.every((member) => isTerminalPhase(member.phase)); // The live label shimmers from elapsed time; no timer is created because // the host owns animation and only re-renders this block. const label = terminal ? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase)) - : shimmerText(ORCHESTRATING_LABEL, { + : shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, { baseToken: 'text', shimmerToken: 'primaryShimmer', frame: Math.floor( @@ -477,9 +509,8 @@ export class DynamicWorkflowMissionControlComponent implements Component { ), windowSize: 4, }); - const paddedLabel = padToWidth(label, ORCHESTRATING_LABEL_WIDTH); + const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH); const prefix = `${loader} ${paddedLabel}`; - const aggregateMembers = this.aggregateMembers(); const completed = aggregateMembers.filter((member) => member.phase === 'completed').length; const failed = aggregateMembers.filter((member) => member.phase === 'failed').length; const cancelled = aggregateMembers.filter((member) => member.phase === 'cancelled').length; @@ -516,7 +547,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { padToWidth('STATE', 6), 'TASK', ].join(' ') - : 'ID STATE TASK'; + : `${padToWidth('ID', 3)} ${padToWidth('STATE', 6)} TASK`; return truncateToWidth(currentTheme.fg('textDim', header), width); } @@ -527,7 +558,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth; const progress = `${renderProgressCube(progressPercent)} ${currentTheme.fg( 'textMuted', - `${String(progressPercent).padStart(3, ' ')}%`, + `${String(Math.floor(progressPercent)).padStart(3, ' ')}%`, )}`; const progressColumn = padToWidth( progress, @@ -536,7 +567,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { const stateColumn = padToWidth(state, 6); const prefix = showProgress ? `${id} ${progressColumn} ${stateColumn} ` - : `${id} ${state} `; + : `${id} ${padToWidth(state, 6)} `; const task = member.item || 'Delegated agent'; const latest = member.latest.length > 0 && member.latest !== task ? member.latest : undefined; const detail = member.phase === 'suspended' || isTerminalPhase(member.phase) @@ -650,7 +681,10 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.recordActivity(member.index, normalizedDetail.length > 0 ? `${label}: ${normalizedDetail}` : label); } - /** Progress only ever advances; stages map to observed events, never to time. */ + /** + * Progress only ever advances; stages map to observed events, never to time. + * Creep is per observed event too (each streamed delta), so no timers exist. + */ private advanceMemberProgress(member: DynamicWorkflowMember, targetPercent: number): void { member.progressPercent = Math.max(member.progressPercent, targetPercent); } @@ -1026,7 +1060,7 @@ function parsePartialJsonString( if (escaped === 'u') { const hex = text.slice(index + 2, index + 6); if (/^[0-9a-fA-F]{4}$/.test(hex)) { - value += String.fromCharCode(Number.parseInt(hex, 16)); + value += String.fromCodePoint(Number.parseInt(hex, 16)); index += 5; continue; } diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 89d85c09..cee41e27 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -31,7 +31,14 @@ export const DYNAMIC_WORKFLOW_RENDERING = { startedProgress: 20, modelActivityProgress: 50, toolActivityProgress: 75, - finalizingProgress: 90, + // Each streamed model delta creeps progress toward a ceiling instead of + // pinning it: p += max(minStep, (ceiling - p) * rate), clamped to the + // ceiling. The minimum step keeps the tail visibly moving instead of + // asymptoting into a stall. Still event-driven, never a timer. + progressCreepRate: 0.03, + progressCreepMinStep: 0.15, + midworkCreepCeiling: 74, + finalizingCreepCeiling: 99, // Two 2×4 Braille cells form a compact 4×4 dotted cube that fills bottom-up. cubeFillLevels: [' ', '⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'], } as const; diff --git a/apps/pythinker-code/src/utils/paths.ts b/apps/pythinker-code/src/utils/paths.ts index 5932eb83..3b46b74d 100644 --- a/apps/pythinker-code/src/utils/paths.ts +++ b/apps/pythinker-code/src/utils/paths.ts @@ -19,6 +19,7 @@ import { PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME, PYTHINKER_CODE_LOG_DIR_NAME, PYTHINKER_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, + PYTHINKER_CODE_UPDATE_INSTALL_LOG_FILE_NAME, PYTHINKER_CODE_UPDATE_INSTALL_STATE_FILE_NAME, PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME, @@ -80,6 +81,13 @@ export function getUpdateInstallLockFile(): string { return join(getDataDir(), PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_INSTALL_LOCK_FILE_NAME); } +/** + * Return the update installer log: `/updates/install.log`. + */ +export function getUpdateInstallLogFile(): string { + return join(getDataDir(), PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_INSTALL_LOG_FILE_NAME); +} + /** * Return the rollout decision log: `/updates/rollout.log`. */ diff --git a/apps/pythinker-code/src/utils/persistence.ts b/apps/pythinker-code/src/utils/persistence.ts index a458ae02..76d801c0 100644 --- a/apps/pythinker-code/src/utils/persistence.ts +++ b/apps/pythinker-code/src/utils/persistence.ts @@ -6,7 +6,7 @@ * these helpers. */ -import { appendFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir, open, readFile, rename, unlink } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import type { z } from 'zod'; @@ -52,14 +52,41 @@ export async function writeJsonFile( filePath: string, schema: z.ZodType, value: T, + options?: { + /** + * Also fsync the file and its parent directory so the write survives a + * crash. Costs two blocking disk flushes — reserve it for state whose + * loss corrupts a workflow (e.g. install.json), not routine caches. + */ + readonly durable?: boolean; + }, ): Promise { assertNonConfigWrite(filePath); const parsed = schema.parse(value); await mkdir(dirname(filePath), { recursive: true }); const tmpPath = tempPathFor(filePath); try { - await writeFile(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + const file = await open(tmpPath, 'wx', 0o600); + try { + await file.writeFile(`${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + if (options?.durable === true) await file.sync(); + } finally { + await file.close(); + } await rename(tmpPath, filePath); + if (options?.durable === true) { + // A synced file plus rename is not crash-durable until the directory + // entry is flushed. Some platforms do not allow opening directories, so + // retain the atomic write even when that final step is unavailable. + const directory = await open(dirname(filePath), 'r').catch(() => null); + if (directory !== null) { + try { + await directory.sync().catch(() => {}); + } finally { + await directory.close(); + } + } + } } catch (error) { await unlink(tmpPath).catch(() => {}); throw error; diff --git a/apps/pythinker-code/test/cli/doctor.test.ts b/apps/pythinker-code/test/cli/doctor.test.ts index 21f0b913..b0250369 100644 --- a/apps/pythinker-code/test/cli/doctor.test.ts +++ b/apps/pythinker-code/test/cli/doctor.test.ts @@ -48,6 +48,7 @@ function makeDeps(): { latest: '1.3.0', checkedAt: '2026-07-29T12:00:00.000Z', autoUpdate: 'on' as const, + mode: 'background-install' as const, }, }), exit: (code) => { @@ -127,12 +128,72 @@ describe('pythinker doctor', () => { ' Package root: /opt/pythinker', ' Executable: /usr/local/bin/node', ' Update channel: CDN staged rollout', - ' Auto-update: on (tui.toml [upgrade].auto_install)', + ' Auto-update: on (installs in background)', ' Latest cached version: 1.3.0 (checked 2026-07-29T12:00:00.000Z)', ].join('\n'), ); }); + it('reports Homebrew preparation and restart activation accurately', async () => { + const { deps, stdout } = makeDeps(); + + const code = await handleDoctor({ + ...deps, + runtimeInfo: async () => ({ + version: '1.2.3', + installSource: 'homebrew', + packageRoot: '/opt/homebrew/Cellar/pythinker-code/1.2.3', + executable: '/opt/homebrew/bin/node', + update: { + latest: '1.3.0', + checkedAt: '2026-07-29T12:00:00.000Z', + autoUpdate: 'on', + mode: 'restart-install', + pendingVersion: '1.3.0', + pendingRequestedBy: 'automatic', + logPath: '/tmp/updates/install.log', + }, + }), + }, {}); + + expect(code).toBe(0); + expect(stdout.join('')).toContain( + [ + ' Auto-update: on (prepare in background; install on next launch)', + ' Latest cached version: 1.3.0 (checked 2026-07-29T12:00:00.000Z)', + ' Prepared update: 1.3.0 (installs on next launch)', + ' Update log: /tmp/updates/install.log', + ].join('\n'), + ); + }); + + it('reports when automatic activation of a prepared update is paused', async () => { + const { deps, stdout } = makeDeps(); + + const code = await handleDoctor({ + ...deps, + runtimeInfo: async () => ({ + version: '1.2.3', + installSource: 'homebrew', + packageRoot: '/opt/homebrew/Cellar/pythinker-code/1.2.3', + executable: '/opt/homebrew/bin/node', + update: { + latest: '1.3.0', + checkedAt: '2026-07-29T12:00:00.000Z', + autoUpdate: 'off', + mode: 'restart-install', + pendingVersion: '1.3.0', + pendingRequestedBy: 'automatic', + }, + }), + }, {}); + + expect(code).toBe(0); + expect(stdout.join('')).toContain( + 'Prepared update: 1.3.0 (automatic activation paused until auto-update is enabled)', + ); + }); + it('warns when multiple Pythinker executables are installed', async () => { const { deps, stdout } = makeDeps(); diff --git a/apps/pythinker-code/test/cli/main.test.ts b/apps/pythinker-code/test/cli/main.test.ts index 8e2b7a4b..a9aeecfe 100644 --- a/apps/pythinker-code/test/cli/main.test.ts +++ b/apps/pythinker-code/test/cli/main.test.ts @@ -7,7 +7,9 @@ import type * as OptionsModule from '#/cli/options'; import { runPrompt } from '#/cli/run-prompt'; import { runShell } from '#/cli/run-shell'; import { formatStartupError } from '#/cli/startup-error'; +import { activatePendingUpdate } from '#/cli/update/activation'; import { runUpdatePreflight } from '#/cli/update/preflight'; +import { dispatchUpdateHelperIfRequested } from '#/cli/update/update-helper'; import { handleMainCommand, handleUpgradeCommand, main } from '#/main'; const mocks = vi.hoisted(() => { @@ -18,6 +20,10 @@ const mocks = vi.hoisted(() => { getVersion: vi.fn(() => '0.0.1-alpha.2'), validateOptions: vi.fn(), runUpdatePreflight: vi.fn(), + activatePendingUpdate: vi.fn(), + isAutoUpdateDisabledByEnv: vi.fn(), + shouldAutoInstallUpdates: vi.fn(), + dispatchUpdateHelperIfRequested: vi.fn(), runShell: vi.fn(), runPrompt: vi.fn(), installCrashHandlers: vi.fn(), @@ -117,6 +123,16 @@ vi.mock('../../src/cli/options', async () => { vi.mock('../../src/cli/update/preflight', () => ({ runUpdatePreflight: mocks.runUpdatePreflight, + isAutoUpdateDisabledByEnv: mocks.isAutoUpdateDisabledByEnv, + shouldAutoInstallUpdates: mocks.shouldAutoInstallUpdates, +})); + +vi.mock('../../src/cli/update/activation', () => ({ + activatePendingUpdate: mocks.activatePendingUpdate, +})); + +vi.mock('../../src/cli/update/update-helper', () => ({ + dispatchUpdateHelperIfRequested: mocks.dispatchUpdateHelperIfRequested, })); vi.mock('../../src/cli/run-shell', () => ({ @@ -197,6 +213,10 @@ describe('main entry command handling', () => { mocks.harness.close.mockResolvedValue(undefined); mocks.shutdownTelemetry.mockResolvedValue(undefined); mocks.handleUpgrade.mockResolvedValue(0); + mocks.activatePendingUpdate.mockResolvedValue({ status: 'none' }); + mocks.isAutoUpdateDisabledByEnv.mockReturnValue(false); + mocks.shouldAutoInstallUpdates.mockResolvedValue(true); + mocks.dispatchUpdateHelperIfRequested.mockReturnValue(false); }); it('flushes a parsed option conflict before exiting', async () => { @@ -253,6 +273,79 @@ describe('main entry command handling', () => { expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); + it('activates a prepared update and re-execs the new Homebrew launcher before preflight', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.activatePendingUpdate.mockResolvedValue({ + status: 'activated', + version: '0.5.0', + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }); + const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + const execve = vi.spyOn(process, 'execve').mockImplementation(() => { + throw new Error('re-exec'); + }); + + try { + await expect(handleMainCommand(opts, '0.4.0')).rejects.toThrow('re-exec'); + expect(activatePendingUpdate).toHaveBeenCalledWith('0.4.0', { + enabled: true, + automaticEnabled: true, + }); + expect(execve).toHaveBeenCalledWith( + '/opt/homebrew/opt/pythinker-code/bin/pythinker', + ['/opt/homebrew/opt/pythinker-code/bin/pythinker', ...process.argv.slice(2)], + process.env, + ); + expect(runUpdatePreflight).not.toHaveBeenCalled(); + expect(runShell).not.toHaveBeenCalled(); + } finally { + execve.mockRestore(); + if (stdinDescriptor === undefined) { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } else { + Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor); + } + if (stdoutDescriptor === undefined) { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } else { + Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor); + } + } + }); + + it('does not block normal startup when pending-update state processing fails', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.activatePendingUpdate.mockRejectedValue(new Error('install state is read-only')); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runShell.mockResolvedValue(undefined); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((( + _chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) => { + const complete = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; + complete?.(); + return true; + }) as never); + + try { + await expect(runHandleMainCommand(opts)).resolves.toBeNull(); + expect(runUpdatePreflight).toHaveBeenCalledOnce(); + expect(runShell).toHaveBeenCalledOnce(); + expect(stderrSpy).toHaveBeenCalledWith( + 'warning: unable to process a pending Pythinker Code update: install state is read-only\n', + expect.any(Function), + ); + } finally { + stderrSpy.mockRestore(); + } + }); + it('runs prompt mode without interactive update preflight', async () => { const opts: CLIOptions = { ...defaultOpts(), @@ -318,6 +411,15 @@ describe('main entry command handling', () => { expect(mocks.parse).toHaveBeenCalledWith(process.argv); }); + it('routes the internal update helper without parsing normal commands', () => { + mocks.dispatchUpdateHelperIfRequested.mockReturnValue(true); + + main(); + + expect(dispatchUpdateHelperIfRequested).toHaveBeenCalledOnce(); + expect(mocks.parse).not.toHaveBeenCalled(); + }); + it('sets the process title during startup', () => { const originalTitle = process.title; try { diff --git a/apps/pythinker-code/test/cli/update/activation.test.ts b/apps/pythinker-code/test/cli/update/activation.test.ts new file mode 100644 index 00000000..bb4be333 --- /dev/null +++ b/apps/pythinker-code/test/cli/update/activation.test.ts @@ -0,0 +1,448 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it, vi } from 'vitest'; + +import { activatePendingUpdate } from '#/cli/update/activation'; +import { + activateHomebrewUpdate, + prepareHomebrewUpdate, + PreparedHomebrewUpdateInvalidError, + type HomebrewCommandRunner, +} from '#/cli/update/homebrew'; +import type { UpdateInstallState, UpdatePreparedHomebrew } from '#/cli/update/types'; + +function preparedHomebrewUpdate(): UpdatePreparedHomebrew { + return { + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + source: 'homebrew', + version: '0.5.0', + preparedAt: '2026-08-04T08:00:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: 'b'.repeat(64), + artifactPath: '/tmp/homebrew-cache/pythinker-code-0.5.0.tgz', + }; +} + +function installState(pending: UpdatePreparedHomebrew): UpdateInstallState { + return { + active: null, + pending, + lastFailure: null, + lastSuccess: null, + }; +} + +describe('pending update activation', () => { + it('activates an exactly prepared Homebrew update and leaves finalization to the new process', async () => { + const pending = preparedHomebrewUpdate(); + const readState = vi.fn().mockResolvedValue(installState(pending)); + const writeState = vi.fn().mockResolvedValue(undefined); + const release = vi.fn().mockResolvedValue(undefined); + const activateHomebrew = vi.fn().mockResolvedValue({ + version: '0.5.0', + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }); + + await expect(activatePendingUpdate('0.4.0', { + enabled: true, + automaticEnabled: true, + deps: { + readState, + writeState, + acquireLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/install.lock', + release, + }), + activateHomebrew, + detectSource: vi.fn().mockResolvedValue('homebrew'), + now: () => new Date('2026-08-04T08:05:00.000Z'), + pid: 42_424, + }, + })).resolves.toEqual({ + status: 'activated', + version: '0.5.0', + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }); + + expect(activateHomebrew).toHaveBeenCalledWith(pending); + expect(writeState).toHaveBeenNthCalledWith(1, expect.objectContaining({ + pending, + active: expect.objectContaining({ + version: '0.5.0', + source: 'homebrew', + operation: 'activate', + jobId: pending.jobId, + pid: 42_424, + }), + })); + expect(writeState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + pending, + lastFailure: null, + lastSuccess: null, + })); + expect(release).toHaveBeenCalledOnce(); + }); + + it('clears the pending record once the activation failure limit is reached', async () => { + const pending = preparedHomebrewUpdate(); + const state: UpdateInstallState = { + ...installState(pending), + lastFailure: { + version: pending.version, + failedAt: '2026-08-04T07:00:00.000Z', + attempts: 2, + operation: 'activate', + message: 'brew upgrade failed', + }, + }; + const writeState = vi.fn().mockResolvedValue(undefined); + const acquireLock = vi.fn(); + + await expect(activatePendingUpdate('0.4.0', { + enabled: true, + automaticEnabled: true, + deps: { + readState: vi.fn().mockResolvedValue(state), + writeState, + acquireLock, + detectSource: vi.fn().mockResolvedValue('homebrew'), + }, + })).resolves.toEqual({ + status: 'failed', + version: pending.version, + message: 'Automatic activation failed 2 times', + }); + + // Terminal: pending is dropped (lastFailure retained) so later launches + // stop retrying and stop reporting an in-progress update. + expect(writeState).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + pending: null, + lastFailure: expect.objectContaining({ operation: 'activate', attempts: 2 }), + })); + expect(acquireLock).not.toHaveBeenCalled(); + }); + + it('finalizes a prepared update only after the target version starts', async () => { + const pending = preparedHomebrewUpdate(); + const readState = vi.fn().mockResolvedValue({ + ...installState(pending), + active: { + version: '0.5.0', + source: 'homebrew', + operation: 'activate', + jobId: pending.jobId, + startedAt: '2026-08-04T08:04:00.000Z', + pid: 42_424, + }, + }); + const writeState = vi.fn().mockResolvedValue(undefined); + + await expect(activatePendingUpdate('0.5.0', { + enabled: true, + automaticEnabled: true, + deps: { + readState, + writeState, + detectSource: vi.fn().mockResolvedValue('homebrew'), + now: () => new Date('2026-08-04T08:05:00.000Z'), + }, + })).resolves.toEqual({ status: 'finalized', version: '0.5.0' }); + + expect(writeState).toHaveBeenCalledWith({ + active: null, + pending: null, + lastFailure: null, + lastSuccess: { + version: '0.5.0', + installedAt: '2026-08-04T08:05:00.000Z', + notifiedAt: null, + }, + }); + }); + + it('discards a prepared Homebrew update when the active installation source changed', async () => { + const pending = preparedHomebrewUpdate(); + const writeState = vi.fn().mockResolvedValue(undefined); + const activateHomebrew = vi.fn(); + + await expect(activatePendingUpdate('0.5.0', { + enabled: true, + automaticEnabled: true, + deps: { + readState: vi.fn().mockResolvedValue(installState(pending)), + writeState, + detectSource: vi.fn().mockResolvedValue('npm-global'), + activateHomebrew, + }, + })).resolves.toEqual({ status: 'invalidated', version: '0.5.0' }); + + expect(writeState).toHaveBeenCalledWith({ + ...installState(pending), + active: null, + pending: null, + }); + expect(activateHomebrew).not.toHaveBeenCalled(); + }); + + it('invalidates stale prepared metadata so preflight can prepare the current formula', async () => { + const pending = preparedHomebrewUpdate(); + const writeState = vi.fn().mockResolvedValue(undefined); + + await expect(activatePendingUpdate('0.4.0', { + enabled: true, + automaticEnabled: true, + deps: { + readState: vi.fn().mockResolvedValue(installState(pending)), + writeState, + acquireLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/install.lock', + release: vi.fn().mockResolvedValue(undefined), + }), + activateHomebrew: vi.fn().mockRejectedValue( + new PreparedHomebrewUpdateInvalidError('formula changed'), + ), + detectSource: vi.fn().mockResolvedValue('homebrew'), + now: () => new Date('2026-08-04T08:05:00.000Z'), + }, + })).resolves.toEqual({ status: 'invalidated', version: '0.5.0' }); + + expect(writeState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + pending: null, + lastFailure: expect.objectContaining({ + operation: 'prepare', + message: 'formula changed', + }), + })); + }); + + it('keeps an automatic update pending when automatic installation was disabled', async () => { + const pending = preparedHomebrewUpdate(); + const detectSource = vi.fn(); + + await expect(activatePendingUpdate('0.4.0', { + enabled: true, + automaticEnabled: false, + deps: { + readState: vi.fn().mockResolvedValue(installState(pending)), + detectSource, + }, + })).resolves.toEqual({ status: 'none' }); + + expect(detectSource).not.toHaveBeenCalled(); + }); + + it('activates a manually requested update even when automatic installation is disabled', async () => { + const pending: UpdatePreparedHomebrew = { + ...preparedHomebrewUpdate(), + requestedBy: 'manual', + }; + + await expect(activatePendingUpdate('0.4.0', { + enabled: true, + automaticEnabled: false, + deps: { + readState: vi.fn().mockResolvedValue(installState(pending)), + writeState: vi.fn().mockResolvedValue(undefined), + acquireLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/install.lock', + release: vi.fn().mockResolvedValue(undefined), + }), + activateHomebrew: vi.fn().mockResolvedValue({ + version: '0.5.0', + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }), + detectSource: vi.fn().mockResolvedValue('homebrew'), + }, + })).resolves.toEqual(expect.objectContaining({ status: 'activated' })); + }); + + it('does not read update state outside an interactive shell', async () => { + const readState = vi.fn(); + + await expect(activatePendingUpdate('0.4.0', { + enabled: false, + automaticEnabled: true, + deps: { readState }, + })).resolves.toEqual({ status: 'none' }); + + expect(readState).not.toHaveBeenCalled(); + }); +}); + +function homebrewInfo(linkedVersion: string | null, version = '0.5.0'): string { + return JSON.stringify({ + formulae: [{ + name: 'pythinker-code', + versions: { stable: version }, + urls: { + stable: { + url: `https://registry.example.com/pythinker-code-${version}.tgz`, + checksum: 'a'.repeat(64), + }, + }, + linked_keg: linkedVersion, + pinned: false, + }], + }); +} + +function homebrewRunner(formulaVersion = '0.5.0'): { + run: HomebrewCommandRunner; + calls: { args: readonly string[]; options: unknown }[]; +} { + let upgraded = false; + const calls: { args: readonly string[]; options: unknown }[] = []; + const run: HomebrewCommandRunner = vi.fn(async (args, options) => { + calls.push({ args, options }); + const command = args.join(' '); + if (command === 'update' || command.startsWith('fetch ')) return { stdout: '', stderr: '' }; + if (command === 'upgrade --formula --build-from-source --no-ask pythinker-code') { + upgraded = true; + return { stdout: '', stderr: '' }; + } + if (command === 'info --json=v2 pythinker-code') { + return { + stdout: homebrewInfo(upgraded ? formulaVersion : '0.4.0', formulaVersion), + stderr: '', + }; + } + if (command === 'formula pythinker-code') { + return { stdout: '/tmp/tap/Formula/pythinker-code.rb\n', stderr: '' }; + } + if (command === '--cache --build-from-source --formula pythinker-code') { + return { stdout: '/tmp/cache/pythinker-code-0.5.0.tgz\n', stderr: '' }; + } + if (command === '--prefix pythinker-code') { + return { stdout: '/opt/homebrew/opt/pythinker-code\n', stderr: '' }; + } + throw new Error(`unexpected brew command: ${command}`); + }); + return { run, calls }; +} + +const FORMULA_SOURCE = 'class PythinkerCode < Formula\nend\n'; + +function homebrewDeps(run: HomebrewCommandRunner) { + return { + run, + hashFile: vi.fn().mockResolvedValue('a'.repeat(64)), + readFormula: vi.fn().mockResolvedValue(FORMULA_SOURCE), + ensureExecutable: vi.fn().mockResolvedValue(undefined), + now: () => new Date('2026-08-04T08:00:00.000Z'), + }; +} + +describe('Homebrew update adapter', () => { + it('refuses to prepare a formula version outside the selected rollout target', async () => { + const { run, calls } = homebrewRunner('0.6.0'); + + await expect(prepareHomebrewUpdate({ + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + requestedVersion: '0.5.0', + requestedBy: 'automatic', + }, { deps: homebrewDeps(run) })).rejects.toThrow( + 'Homebrew formula 0.6.0 does not match requested update 0.5.0', + ); + expect(calls.some(({ args }) => args[0] === 'fetch')).toBe(false); + }); + + it('prepares and verifies the exact source artifact in the background', async () => { + const { run, calls } = homebrewRunner(); + const deps = homebrewDeps(run); + + const prepared = await prepareHomebrewUpdate({ + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + requestedVersion: '0.5.0', + requestedBy: 'automatic', + }, { deps }); + + expect(prepared).toEqual({ + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + source: 'homebrew', + version: '0.5.0', + preparedAt: '2026-08-04T08:00:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: createHash('sha256').update(FORMULA_SOURCE).digest('hex'), + artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', + }); + expect(calls.some(({ args }) => + args.join(' ') === 'fetch --build-from-source --retry --formula pythinker-code' + )).toBe(true); + expect(deps.hashFile).toHaveBeenCalledWith('/tmp/cache/pythinker-code-0.5.0.tgz'); + }); + + it('freezes Homebrew metadata, installs, verifies the linked keg, and returns the new executable', async () => { + const { run, calls } = homebrewRunner(); + const deps = homebrewDeps(run); + const prepared = await prepareHomebrewUpdate({ + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + requestedVersion: '0.5.0', + requestedBy: 'automatic', + }, { deps }); + + await expect(activateHomebrewUpdate(prepared, { deps })).resolves.toEqual({ + version: '0.5.0', + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }); + + const upgrade = calls.find(({ args }) => args[0] === 'upgrade'); + expect(upgrade).toEqual(expect.objectContaining({ + options: expect.objectContaining({ + inheritOutput: true, + env: expect.objectContaining({ + HOMEBREW_NO_AUTO_UPDATE: '1', + HOMEBREW_NO_INSTALL_CLEANUP: '1', + }), + }), + })); + expect(deps.ensureExecutable).toHaveBeenCalledWith( + '/opt/homebrew/opt/pythinker-code/bin/pythinker', + ); + }); + + it('refuses activation when the prepared artifact checksum changes', async () => { + const { run } = homebrewRunner(); + const deps = homebrewDeps(run); + const prepared = await prepareHomebrewUpdate({ + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + requestedVersion: '0.5.0', + requestedBy: 'automatic', + }, { deps }); + deps.hashFile.mockResolvedValue('c'.repeat(64)); + + await expect(activateHomebrewUpdate(prepared, { deps })).rejects.toThrow( + 'Prepared Homebrew artifact failed SHA-256 verification', + ); + expect(run).not.toHaveBeenCalledWith( + expect.arrayContaining(['upgrade']), + expect.anything(), + ); + }); + + it('refuses activation when the formula changed after preparation', async () => { + const { run } = homebrewRunner(); + const deps = homebrewDeps(run); + const prepared = await prepareHomebrewUpdate({ + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + requestedVersion: '0.5.0', + requestedBy: 'automatic', + }, { deps }); + deps.readFormula.mockResolvedValue('class ChangedFormula < Formula\nend\n'); + + await expect(activateHomebrewUpdate(prepared, { deps })).rejects.toThrow( + 'Homebrew formula changed after the update was prepared', + ); + expect(run).not.toHaveBeenCalledWith( + expect.arrayContaining(['upgrade']), + expect.anything(), + ); + }); +}); diff --git a/apps/pythinker-code/test/cli/update/cache.test.ts b/apps/pythinker-code/test/cli/update/cache.test.ts index 8f6372da..dbc50624 100644 --- a/apps/pythinker-code/test/cli/update/cache.test.ts +++ b/apps/pythinker-code/test/cli/update/cache.test.ts @@ -11,7 +11,11 @@ import { } from '#/cli/update/install-state'; import { readUpdateCache, writeUpdateCache } from '#/cli/update/cache'; import { emptyUpdateCache, type UpdateInstallState } from '#/cli/update/types'; -import { getUpdateInstallStateFile, getUpdateStateFile } from '#/utils/paths'; +import { + getUpdateInstallLogFile, + getUpdateInstallStateFile, + getUpdateStateFile, +} from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -147,6 +151,7 @@ describe('update install state', () => { source: 'npm-global', startedAt: '2026-04-23T08:00:00.000Z', }, + pending: null, lastFailure: null, lastSuccess: null, }; @@ -164,6 +169,18 @@ describe('update install state', () => { startedAt: '2026-04-23T08:00:00.000Z', pid: 42_424, }, + pending: { + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + source: 'homebrew', + version: '0.5.0', + preparedAt: '2026-04-23T08:05:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: 'b'.repeat(64), + artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', + }, lastFailure: { version: '0.4.0', failedAt: '2026-04-22T08:00:00.000Z', @@ -179,6 +196,7 @@ describe('update install state', () => { await writeUpdateInstallState(state); expect(getUpdateInstallStateFile()).toBe(join(dir, 'updates', 'install.json')); + expect(getUpdateInstallLogFile()).toBe(join(dir, 'updates', 'install.log')); const persisted = JSON.parse(readFileSync(getUpdateInstallStateFile(), 'utf-8')) as { readonly active: { readonly pid?: number } | null; }; diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 183438b9..9a6130f4 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -22,6 +22,7 @@ import { type UpdateCache, type UpdateInstallState, type UpdateManifest, + type UpdatePreparedHomebrew, } from '#/cli/update/types'; import { DEFAULT_STATUS_LINE_CONFIG, @@ -53,6 +54,7 @@ vi.mock('../../../src/cli/update/install-lock', () => ({ vi.mock('../../../src/cli/update/install-state', () => ({ emptyUpdateInstallState: () => ({ active: null, + pending: null, lastFailure: null, lastSuccess: null, }), @@ -161,12 +163,28 @@ function releasedForEveryone(version: string): UpdateManifest { function installState(overrides: Partial = {}): UpdateInstallState { return { active: null, + pending: null, lastFailure: null, lastSuccess: null, ...overrides, }; } +function preparedHomebrewUpdate(): UpdatePreparedHomebrew { + return { + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + source: 'homebrew', + version: '0.5.0', + preparedAt: '2026-08-04T08:00:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: 'b'.repeat(64), + artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', + }; +} + function tuiConfig(overrides: Partial = {}): TuiConfig { return { theme: 'auto', @@ -463,15 +481,48 @@ describe('runUpdatePreflight', () => { ); }); - it('homebrew: prints manual brew upgrade command, does not spawn', async () => { + it('homebrew: prepares the update in a detached helper for activation on restart', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('homebrew'); + const release = vi.fn().mockResolvedValue(undefined); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }); + const child = Object.assign(new EventEmitter(), { pid: 42_424, unref: vi.fn() }); + mocks.spawn.mockImplementation(() => { + queueMicrotask(() => { child.emit('spawn'); }); + return child; + }); const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(stdout.join('')).toContain('brew upgrade pythinker-code'); + + expect(stdout).toEqual([]); expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + [ + process.argv[1], + '__update_helper', + 'prepare-homebrew', + expect.any(String), + '0.5.0', + 'automatic', + ], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + version: '0.5.0', + source: 'homebrew', + operation: 'prepare', + jobId: expect.any(String), + }), + })); + expect(child.unref).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); }); it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { @@ -1466,6 +1517,7 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'started', version: '0.5.0', + installOnRestart: false, }); await flushBackgroundInstall(); expect(mocks.spawn).toHaveBeenCalledTimes(1); @@ -1479,6 +1531,7 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'started', version: '0.5.0', + installOnRestart: false, }); }); @@ -1491,19 +1544,73 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'started', version: '0.5.0', + installOnRestart: false, + }); + }); + + it('prepares a Homebrew update for installation on the next launch', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const child = Object.assign(new EventEmitter(), { pid: 42_424, unref: vi.fn() }); + mocks.spawn.mockImplementation(() => { + queueMicrotask(() => { child.emit('spawn'); }); + return child; + }); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: true, }); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + [ + process.argv[1], + '__update_helper', + 'prepare-homebrew', + expect.any(String), + '0.5.0', + 'manual', + ], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(mocks.spawn).toHaveBeenCalledOnce(); }); - it('returns the manual command when the source cannot auto-install', async () => { + it('clears the preparation lease when the detached helper cannot start', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('homebrew'); + mocks.spawn.mockImplementation(() => { throw new Error('spawn failed'); }); await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'manual', + status: 'check-failed', + message: 'spawn failed', + }); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + operation: 'prepare', + message: 'spawn failed', + }), + })); + }); + + it('promotes a prepared automatic update when the user explicitly requests it', async () => { + const pending = preparedHomebrewUpdate(); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ pending })); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'in-progress', version: '0.5.0', - command: 'brew upgrade pythinker-code', - source: 'homebrew', + installOnRestart: true, + readyToInstall: true, }); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + pending: { ...pending, requestedBy: 'manual' }, + })); expect(mocks.spawn).not.toHaveBeenCalled(); }); @@ -1517,6 +1624,8 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'in-progress', version: '0.5.0', + installOnRestart: false, + readyToInstall: false, }); expect(mocks.spawn).not.toHaveBeenCalled(); }); diff --git a/apps/pythinker-code/test/cli/update/update-helper.test.ts b/apps/pythinker-code/test/cli/update/update-helper.test.ts new file mode 100644 index 00000000..d53df6dc --- /dev/null +++ b/apps/pythinker-code/test/cli/update/update-helper.test.ts @@ -0,0 +1,308 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { activatePendingUpdate } from '#/cli/update/activation'; +import { readUpdateInstallState, writeUpdateInstallState } from '#/cli/update/install-state'; +import { runUpdateHelper } from '#/cli/update/update-helper'; +import type { UpdatePreparedHomebrew } from '#/cli/update/types'; + +const mocks = vi.hoisted(() => ({ + prepareHomebrewUpdate: vi.fn(), +})); + +vi.mock('#/cli/update/homebrew', async () => { + const actual = await vi.importActual( + '#/cli/update/homebrew', + ); + return { + ...actual, + prepareHomebrewUpdate: mocks.prepareHomebrewUpdate, + }; +}); + +const JOB_ID = '7e717f78-70c6-4f7c-9745-ceb45822d24b'; +let dir: string; + +function preparedUpdate(): UpdatePreparedHomebrew { + return { + jobId: JOB_ID, + source: 'homebrew', + version: '0.5.0', + preparedAt: '2026-08-04T08:00:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: 'b'.repeat(64), + artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', + }; +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'pythinker-update-helper-')); + process.env['PYTHINKER_CODE_HOME'] = dir; + await writeUpdateInstallState({ + active: { + version: '0.5.0', + source: 'homebrew', + operation: 'prepare', + jobId: JOB_ID, + startedAt: '2026-08-04T07:59:00.000Z', + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }); +}); + +afterEach(async () => { + delete process.env['PYTHINKER_CODE_HOME']; + await rm(dir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +describe('update helper', () => { + it('owns preparation completion after the launching process hands off', async () => { + mocks.prepareHomebrewUpdate.mockImplementation(async () => { + const running = await readUpdateInstallState(); + expect(running.active).toEqual(expect.objectContaining({ + jobId: JOB_ID, + operation: 'prepare', + pid: process.pid, + })); + return preparedUpdate(); + }); + + await expect( + runUpdateHelper(['prepare-homebrew', JOB_ID, '0.5.0', 'automatic']), + ).resolves.toBe(0); + + expect(mocks.prepareHomebrewUpdate).toHaveBeenCalledWith( + { jobId: JOB_ID, requestedVersion: '0.5.0', requestedBy: 'automatic' }, + expect.anything(), + ); + await expect(readUpdateInstallState()).resolves.toEqual({ + active: null, + pending: preparedUpdate(), + lastFailure: null, + lastSuccess: null, + }); + }); + + it('persists a preparation failure with a retry count and diagnostic message', async () => { + mocks.prepareHomebrewUpdate.mockRejectedValue(new Error('formula checksum mismatch')); + + await expect( + runUpdateHelper(['prepare-homebrew', JOB_ID, '0.5.0', 'automatic']), + ).resolves.toBe(1); + + await expect(readUpdateInstallState()).resolves.toEqual(expect.objectContaining({ + active: null, + pending: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + operation: 'prepare', + message: 'formula checksum mismatch', + }), + })); + }); + + it('rejects malformed helper arguments without changing install state', async () => { + const before = await readUpdateInstallState(); + + await expect(runUpdateHelper(['prepare-homebrew', 'bad-id', 'nope'])).resolves.toBe(2); + + await expect(readUpdateInstallState()).resolves.toEqual(before); + expect(mocks.prepareHomebrewUpdate).not.toHaveBeenCalled(); + }); + + it('requires the active source and version to match the helper request', async () => { + const mismatched = await readUpdateInstallState(); + await writeUpdateInstallState({ + ...mismatched, + active: { + version: '0.6.0', + source: 'npm-global', + operation: 'prepare', + jobId: JOB_ID, + startedAt: '2026-08-04T07:59:00.000Z', + }, + }); + + await expect( + runUpdateHelper(['prepare-homebrew', JOB_ID, '0.5.0', 'automatic']), + ).resolves.toBe(0); + + expect(mocks.prepareHomebrewUpdate).not.toHaveBeenCalled(); + await expect(readUpdateInstallState()).resolves.toEqual(expect.objectContaining({ + active: expect.objectContaining({ source: 'npm-global', version: '0.6.0' }), + pending: null, + })); + }); + + // The fake `brew` uses a POSIX shebang, chmod, and `:` PATH separators. + it.skipIf(process.platform === 'win32')( + 'finishes preparation in a detached process after its parent exits', + async () => { + const fakeBin = join(dir, 'bin'); + const fixtureDir = join(dir, 'fixtures'); + const formulaPath = join(fixtureDir, 'pythinker-code.rb'); + const artifactPath = join(fixtureDir, 'pythinker-code-0.5.0.tgz'); + const artifact = Buffer.from('verified package archive'); + const artifactSha256 = createHash('sha256').update(artifact).digest('hex'); + await Promise.all([mkdir(fakeBin), mkdir(fixtureDir)]); + await writeFile(formulaPath, 'class PythinkerCode < Formula\nend\n'); + + const fakeBrewPath = join(fakeBin, 'brew'); + await writeFile(fakeBrewPath, `#!/usr/bin/env node +import { writeFile } from 'node:fs/promises'; +const args = process.argv.slice(2).join(' '); +if (args === 'update') process.exit(0); +if (args === 'info --json=v2 pythinker-code') { + process.stdout.write(${JSON.stringify(homebrewInfoFixture(artifactSha256))}); + process.exit(0); +} +if (args === 'formula pythinker-code') { + process.stdout.write(${JSON.stringify(`${formulaPath}\n`)}); + process.exit(0); +} +if (args === '--cache --build-from-source --formula pythinker-code') { + process.stdout.write(${JSON.stringify(`${artifactPath}\n`)}); + process.exit(0); +} +if (args === '--prefix pythinker-code') { + process.stdout.write('/opt/homebrew/opt/pythinker-code\\n'); + process.exit(0); +} +if (args === 'fetch --build-from-source --retry --formula pythinker-code') { + await new Promise((resolve) => setTimeout(resolve, 250)); + await writeFile(${JSON.stringify(artifactPath)}, Buffer.from('verified package archive')); + process.exit(0); +} +process.stderr.write('unexpected fake brew command: ' + args + '\\n'); +process.exit(1); +`); + await chmod(fakeBrewPath, 0o755); + + const repoRoot = resolve(import.meta.dirname, '../../../../..'); + const appRoot = join(repoRoot, 'apps', 'pythinker-code'); + const rawTextLoader = join(repoRoot, 'build', 'register-raw-text-loader.mjs'); + const mainPath = join(appRoot, 'src', 'main.ts'); + const tsconfigPath = join(dir, 'tsx-tsconfig.json'); + await writeFile(tsconfigPath, JSON.stringify({ + extends: join(appRoot, 'tsconfig.json'), + include: [join(appRoot, 'src/**/*.ts'), join(repoRoot, 'packages/**/*.ts')], + })); + const helperOutputPath = join(dir, 'helper-output.log'); + const parentPath = join(dir, 'detached-parent.mjs'); + await writeFile(parentPath, ` +import { spawn } from 'node:child_process'; +import { closeSync, openSync } from 'node:fs'; +const output = openSync(${JSON.stringify(helperOutputPath)}, 'a'); +const child = spawn(process.execPath, [ + '--import', ${JSON.stringify(rawTextLoader)}, + '--import', 'tsx', + ${JSON.stringify(mainPath)}, + '__update_helper', + 'prepare-homebrew', + ${JSON.stringify(JOB_ID)}, + '0.5.0', + 'automatic', +], { detached: true, env: process.env, stdio: ['ignore', output, output] }); +child.once('error', () => { process.exitCode = 1; }); +child.once('spawn', () => { child.unref(); closeSync(output); }); +`); + + await runProcess(process.execPath, [parentPath], { + ...process.env, + PATH: `${fakeBin}:${process.env['PATH'] ?? ''}`, + PYTHINKER_CODE_HOME: dir, + PYTHINKER_CODE_UPDATE_HELPER: '1', + TSX_TSCONFIG_PATH: tsconfigPath, + }); + + try { + await vi.waitFor(async () => { + const state = await readUpdateInstallState(); + expect(state.pending).toEqual(expect.objectContaining({ + jobId: JOB_ID, + version: '0.5.0', + requestedBy: 'automatic', + artifactSha256, + })); + expect(state.active).toBeNull(); + }, { timeout: 8_000, interval: 50 }); + } catch (error) { + const helperOutput = await readFile(helperOutputPath, 'utf-8').catch(() => ''); + throw new Error(`detached helper did not finish: ${helperOutput}`, { cause: error }); + } + + await expect(activatePendingUpdate('0.4.0', { + enabled: true, + automaticEnabled: true, + deps: { + detectSource: async () => 'homebrew', + activateHomebrew: async (prepared) => ({ + version: prepared.version, + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }), + }, + })).resolves.toEqual({ + status: 'activated', + version: '0.5.0', + executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', + }); + + await expect(activatePendingUpdate('0.5.0', { + enabled: true, + automaticEnabled: true, + deps: { detectSource: async () => 'homebrew' }, + })).resolves.toEqual({ status: 'finalized', version: '0.5.0' }); + await expect(readUpdateInstallState()).resolves.toEqual(expect.objectContaining({ + active: null, + pending: null, + lastSuccess: expect.objectContaining({ version: '0.5.0' }), + })); + }, 12_000); +}); + +function homebrewInfoFixture(artifactSha256: string): string { + return JSON.stringify({ + formulae: [{ + name: 'pythinker-code', + versions: { stable: '0.5.0' }, + urls: { + stable: { + url: 'https://registry.example.com/pythinker-code-0.5.0.tgz', + checksum: artifactSha256, + }, + }, + linked_keg: '0.4.0', + pinned: false, + }], + }); +} + +async function runProcess( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +): Promise { + await new Promise((resolveProcess, reject) => { + const child = spawn(command, [...args], { env, stdio: 'ignore' }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolveProcess(); + return; + } + reject(new Error(`${command} exited with code ${String(code)}`)); + }); + }); +} diff --git a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts index 7c342f5f..9601f929 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts @@ -153,7 +153,9 @@ describe('ChoicePickerComponent', () => { }); const upgradePreferenceOutput = upgradePreference.render(120).map(strip); expect(upgradePreferenceOutput).toContain(' ❯ On ← current'); - expect(upgradePreferenceOutput).toContain(' Install new versions in the background.'); + expect(upgradePreferenceOutput).toContain( + ' Update automatically in the background.', + ); }); it('routes Space into the query for searchable lists instead of selecting', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 235b53ea..c559597d 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -7,7 +7,7 @@ import { type DynamicWorkflowMissionControlOptions, dynamicWorkflowResultSummaryFromOutput, } from '#/tui/components/messages/dynamic-workflow-mission-control'; -import { BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; +import { BRAILLE_SPINNER_INTERVAL_MS, DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering'; import { currentTheme, darkColors } from '#/tui/theme'; const DESCRIPTION = 'Review the interface'; @@ -29,9 +29,15 @@ function memberLine(output: string, index: number): string { return line; } +function displayedPercent(output: string, index: number): number { + const match = /(\d+)%/u.exec(memberLine(output, index)); + if (match === null) throw new Error(`Missing percent for member ${String(index)}`); + return Number(match[1]); +} + function aggregateLine(output: string): string { const line = output.split('\n').find((candidate) => - /\b(?:Orchestrating|Completed|Failed|Cancelled)\b/u.test(strip(candidate)) + /\b(?:Orchestrating|Finalizing|Completed|Failed|Cancelled)\b/u.test(strip(candidate)) ); if (line === undefined) throw new Error('Missing Dynamic Workflow aggregate'); return line; @@ -508,7 +514,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); expectProgress(75, '⣶'); component.appendModelDelta({ agentId: 'agent-1', delta: 'Summarizing' }); - const activeOutput = expectProgress(90, '⣷'); + const activeOutput = expectProgress(75, '⣶'); expect(aggregateLine(activeOutput)).toContain('0/1 complete'); expect(aggregateLine(activeOutput)).not.toMatch(/\b\d+%/u); expect(aggregateLine(activeOutput)).not.toContain('━'); @@ -591,4 +597,116 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(output.includes('PROGRESS')).toBe(showsProgress); }, ); + + it('creeps past 90 across streamed deltas and completes only on the terminal event', () => { + const component = createComponent(); + component.updateArgs({ items: ['Long streaming work'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); + + for (let index = 0; index < 10; index += 1) { + component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); + } + const early = displayedPercent(renderText(component, 100), 1); + // No snap to 90: the finalizing phase climbs from 75 instead of jumping. + expect(early).toBeGreaterThan(75); + expect(early).toBeLessThan(90); + + for (let index = 0; index < 200; index += 1) { + component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' }); + } + const late = displayedPercent(renderText(component, 100), 1); + expect(late).toBeGreaterThan(90); + expect(late).toBeLessThan(100); + + component.markCompleted('agent-1', 'Done'); + expect(displayedPercent(renderText(component, 100), 1)).toBe(100); + }); + + it('keeps mid-work delta creep under the tool-activity stage until a tool call lifts it', () => { + const component = createComponent(); + component.updateArgs({ items: ['Chatty work'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + + for (let index = 0; index < 300; index += 1) { + component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' }); + } + const midwork = displayedPercent(renderText(component, 100), 1); + expect(midwork).toBeGreaterThan(50); + expect(midwork).toBeLessThan(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); + + component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); + expect(displayedPercent(renderText(component, 100), 1)) + .toBeGreaterThanOrEqual(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); + }); + + it('shimmers Finalizing once every member is terminal but the result has not arrived', () => { + const component = createComponent(); + component.updateArgs({ items: ['One', 'Two'] }); + component.markInputComplete(); + component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); + component.registerSubagent({ agentId: 'agent-2', dynamicWorkflowIndex: 2 }); + component.markStarted('agent-1'); + component.markStarted('agent-2'); + component.markCompleted('agent-1', 'Done'); + + const running = renderText(component, 100); + expect(running).toContain('Orchestrating'); + expect(running).not.toContain('Finalizing'); + + component.markCompleted('agent-2', 'Done'); + const finalizing = renderText(component, 100); + expect(finalizing).toContain('Finalizing'); + expect(finalizing).not.toContain('Orchestrating'); + + component.applyResult([ + '', + 'Done', + 'Done', + '', + ].join('\n')); + const done = renderText(component, 100); + expect(done).toContain('✓ Completed'); + expect(done).not.toContain('Finalizing'); + }); + + it('keeps Orchestrating while an out-of-band member beyond knownTotal still runs', () => { + const component = createComponent(); + component.updateArgs({ items: ['One', 'Two'] }); + component.markInputComplete(); + component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); + component.registerSubagent({ agentId: 'agent-2', dynamicWorkflowIndex: 2 }); + component.registerSubagent({ agentId: 'agent-3', dynamicWorkflowIndex: 3 }); + component.markStarted('agent-3'); + component.markCompleted('agent-1', 'Done'); + component.markCompleted('agent-2', 'Done'); + + const output = renderText(component, 100); + expect(output).toContain('● RUN'); + expect(output).toContain('Orchestrating'); + expect(output).not.toContain('Finalizing'); + + component.markCompleted('agent-3', 'Done'); + expect(renderText(component, 100)).toContain('Finalizing'); + }); + + it('aligns narrow member rows and the header on the same task column', () => { + const component = prepareObservedWorkflow(); + const output = renderText(component, 50); + const unframe = (line: string) => line.replace(/^│ /u, ''); + const running = unframe(memberLine(output, 1)); + const completed = unframe(memberLine(output, 2)); + const headerLine = output.split('\n').find((line) => line.includes('STATE')); + if (headerLine === undefined) throw new Error('Missing Dynamic Workflow table header'); + const header = unframe(headerLine); + + const taskColumn = running.indexOf('Layout hierarchy'); + expect(taskColumn).toBeGreaterThan(0); + expect(completed.indexOf('Interaction audit')).toBe(taskColumn); + expect(header.indexOf('TASK')).toBe(taskColumn); + }); }); diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 81d3ae5d..ccb2bb57 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -259,7 +259,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences, inclu | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | -| `[upgrade].auto_install` | `boolean` | `true` | Whether new versions are installed automatically | +| `[upgrade].auto_install` | `boolean` | `true` | Whether new versions update automatically. Homebrew downloads and verifies in the background, then installs on the next interactive launch. An explicit `/update` request still completes when this is `false` | | `[status_line].show_model` | `boolean` | `true` | Show the model name and session spend | | `[status_line].show_effort` | `boolean` | `true` | Show Thinking effort when `show_model` is also `true` | | `[status_line].show_token_speed` | `boolean` | `true` | Show live token speed when `show_model` is also `true` | diff --git a/docs/configuration/data-locations.md b/docs/configuration/data-locations.md index 80aab5b2..94121bc1 100644 --- a/docs/configuration/data-locations.md +++ b/docs/configuration/data-locations.md @@ -51,6 +51,7 @@ $PYTHINKER_CODE_HOME (default: ~/.pythinker-code) │ ├── latest.json │ ├── install.json │ ├── install.lock +│ ├── install.log │ └── rollout.log └── user-history/ └── .jsonl @@ -94,7 +95,7 @@ The first time the `Grep` tool needs ripgrep, the CLI can automatically download When reporting a bug, prefer exporting the relevant session with `pythinker export` (see [pythinker command](../reference/pythinker-command.md)); the session log is included in the export by default. Add `--no-include-global-log` if you do not want to share the global log. -The files under `updates/` (`latest.json`, `install.json`, `install.lock`, `rollout.log`) are maintained automatically by the auto-update mechanism and normally do not need manual editing. `rollout.log` records which staged-rollout case each update check hit, which helps explain when a device will receive a new release. +The files under `updates/` are maintained automatically and normally do not need manual editing. `install.json` records active, prepared, failed, and completed update state; `install.lock` serializes concurrent launches; and `install.log` preserves package-manager output. `rollout.log` records which release-rollout case each update check hit, which helps explain when a device will receive a new release. ## Input history @@ -111,7 +112,7 @@ Deleting the data root directory (`~/.pythinker-code/` or the path set by `PYTHI | Clear all sessions | Delete `~/.pythinker-code/sessions/` and `session_index.jsonl` | | Clear diagnostic logs | Delete `~/.pythinker-code/logs/` | | Clear input history | Delete `~/.pythinker-code/user-history/` | -| Reset update state | Delete `~/.pythinker-code/updates/latest.json` | +| Reset update state | Delete `~/.pythinker-code/updates/latest.json`, `install.json`, and `install.lock` when no update process is running | | Force re-download of managed `rg` and `fd` | Delete `~/.pythinker-code/bin/` | | Clear provider OAuth login state | Run `/logout`, or delete the corresponding `credentials/.json` | | Clear MCP server OAuth login state | Delete `credentials/mcp/` (`/logout` does not clear MCP credentials) | diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index e5778ee0..16569039 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -152,7 +152,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `pythinker` provider only (global — independent of `PYTHINKER_MODEL_NAME`) | Number, e.g. `0.3` | | `PYTHINKER_MODEL_TOP_P` | Nucleus-sampling `top_p` for every request; applies to the `pythinker` provider only (global) | Number, e.g. `0.95` | | `PYTHINKER_MODEL_THINKING_KEEP` | Pythoughts preserved-thinking passthrough (`thinking.keep`); applies to the `pythinker` provider only, and only while Thinking is on | A value the API accepts, e.g. `all` | -| `PYTHINKER_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `PYTHINKER_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | +| `PYTHINKER_CODE_NO_AUTO_UPDATE` | Disable automatic update checks, background preparation or installation, restart activation, and prompts. An explicit `/update` request is still completed; the legacy alias `PYTHINKER_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `PYTHINKER_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | ## Diagnostic logs diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index b5723f11..160e2e29 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -61,7 +61,7 @@ After installation, verify that the executable is ready: pythinker --version ``` -**Upgrade**: run `pythinker upgrade` — the CLI checks for the latest version and presents update options. Choose `Install update now` to upgrade based on your current install source. You can also upgrade directly via the package manager: +**Upgrade**: automatic updates are enabled by default. npm, pnpm, yarn, bun, and supported native installations update in the background. Homebrew installations download and verify the formula source in the background, then install it on the next interactive launch and restart into the new version. Run `pythinker upgrade` to check immediately. For npm, pnpm, yarn, bun, and macOS / Linux native installations it offers to install the update right away; for Homebrew and Windows native installations it prints the command to run. You can also upgrade directly via the package manager: ```sh npm install -g @pythoughts/pythinker-code@latest diff --git a/docs/reference/pythinker-command.md b/docs/reference/pythinker-command.md index 9800d1c6..c7379ed7 100644 --- a/docs/reference/pythinker-command.md +++ b/docs/reference/pythinker-command.md @@ -217,7 +217,7 @@ pythinker doctor | `pythinker doctor config [path]` | Validate only `config.toml`, using `path` instead of the default file when provided | | `pythinker doctor tui [path]` | Validate only `tui.toml`, using `path` instead of the default file when provided | -When an explicit path is passed, the file must exist. The command exits with `0` when all checked files are valid or skipped, and `1` when any requested file is missing or invalid. +When an explicit path is passed, the file must exist. The default report also shows the effective automatic-update mode, a prepared Homebrew version or active operation, the last failure, and the installer log path. The command exits with `0` when all checked files are valid or skipped, and `1` when any requested file is missing or invalid. ```sh # Check the default config files @@ -276,7 +276,7 @@ Immediately check for the latest version and display an update prompt; exits aft pythinker upgrade ``` -For global npm, pnpm, yarn, bun, and macOS / Linux native installations, `pythinker upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. When the current installation method cannot be upgraded automatically (e.g., Windows native installation), the manual update command is printed instead. +For global npm, pnpm, yarn, bun, and macOS / Linux native installations, `pythinker upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. Homebrew and Windows native installations print their package-manager or installer command instead. During normal interactive launches, automatic Homebrew updates use a separate restart-safe flow: the source archive is prepared and verified in the background, then installed on the next launch. ### `pythinker dashboard` diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index bc1c55b1..0df285e3 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -44,6 +44,15 @@ import { export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; +/** + * Default hard cap on compaction output tokens when `maxOutputSize` is not + * configured on the model alias. Without this, compaction falls back to the + * full context window size, which exceeds the `max_tokens` ceiling enforced + * by many OpenAI-compatible providers. 128k matches the chat-completions + * ceiling applied by the OpenAI Legacy provider. + */ +const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024; + class CompactionTruncatedError extends Error { constructor() { super('Compaction response was truncated before producing a complete summary.'); @@ -299,12 +308,29 @@ export class FullCompaction { await this.triggerPreCompactHook(data, tokensBefore, signal); const model = this.agent.config.model; + const capability = this.agent.config.modelCapabilities; + const maxContextTokens = capability.max_context_tokens; + // When the model's context window is known and the user has not set + // `maxOutputSize`, cap compaction output to a safe default so a large + // context window does not push `max_tokens` past the provider's ceiling. + // When the window is unknown (maxContextTokens === 0), leave + // `maxOutputSize` unset so `resolveCompletionBudget` falls back to the + // conservative unknown-context fallback. + const defaultCompactionCap = + maxContextTokens > 0 + ? Math.min(maxContextTokens, DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS) + : undefined; const provider = applyCompletionBudget({ provider: this.agent.config.provider, budget: resolveCompletionBudget({ + maxOutputSize: this.agent.config.maxOutputSize ?? defaultCompactionCap, reservedContextSize: this.agent.pythinkerConfig?.loopControl?.reservedContextSize, }), - capability: this.agent.config.modelCapabilities, + capability, + // The compaction request replays (a projection of) the existing + // history, so it is by definition near the top of the window — + // size max_tokens to what actually remains. + usedContextTokens: tokensBefore, }); const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index bdfc5118..92da6b3e 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -311,6 +311,7 @@ export class Agent { capability: this.config.modelCapabilities, generate: this.generate, completionBudgetConfig, + usedContextTokens: () => this.context.tokenCount, }); } diff --git a/packages/agent-core/src/agent/turn/kosong-llm.ts b/packages/agent-core/src/agent/turn/kosong-llm.ts index 77529e2a..f6246030 100644 --- a/packages/agent-core/src/agent/turn/kosong-llm.ts +++ b/packages/agent-core/src/agent/turn/kosong-llm.ts @@ -55,6 +55,12 @@ export interface KosongLLMConfig { * final cap is applied to each request. */ readonly completionBudgetConfig?: CompletionBudgetConfig | undefined; + /** + * Returns the number of context tokens already consumed by the latest + * completed step (API-reported input + output). Used by chat-completions + * providers to size the completion budget to the remaining context window. + */ + readonly usedContextTokens?: (() => number) | undefined; } export class KosongLLM implements LLM { @@ -65,6 +71,7 @@ export class KosongLLM implements LLM { private readonly provider: ChatProvider; private readonly generate: GenerateFn; private readonly completionBudgetConfig: CompletionBudgetConfig | undefined; + private readonly usedContextTokens: (() => number) | undefined; constructor(config: KosongLLMConfig) { this.provider = config.provider; @@ -73,6 +80,7 @@ export class KosongLLM implements LLM { this.capability = config.capability; this.generate = config.generate ?? kosongGenerate; this.completionBudgetConfig = config.completionBudgetConfig; + this.usedContextTokens = config.usedContextTokens; } async chat(params: LLMChatParams): Promise { @@ -98,6 +106,7 @@ export class KosongLLM implements LLM { provider: this.provider, budget: this.completionBudgetConfig, capability: this.capability, + usedContextTokens: this.usedContextTokens?.(), }); const options: GenerateOptionsWithRequestLogFields = { signal: params.signal, diff --git a/packages/agent-core/src/utils/completion-budget.ts b/packages/agent-core/src/utils/completion-budget.ts index 7d4a559c..3ad7e32b 100644 --- a/packages/agent-core/src/utils/completion-budget.ts +++ b/packages/agent-core/src/utils/completion-budget.ts @@ -54,14 +54,19 @@ function parseEnvBudget(raw: string | undefined): EnvBudget { export function computeCompletionBudgetCap(args: { readonly budget: CompletionBudgetConfig; readonly capability: ModelCapability | undefined; + readonly usedContextTokens?: number; }): number { const maxCtx = args.capability?.max_context_tokens ?? 0; - // The provider backend computes the safe request-specific value from the - // serialized prompt. Locally using the largest cap avoids cutting off - // thinking before the model produces a summary. - const cap = + // Start from the largest cap so thinking is not cut off before the model + // produces a summary, then shrink to the remaining context window when the + // used-token count is known so `input + max_tokens` stays within provider + // limits that validate the sum against the window. + let cap = args.budget.hardCap ?? (maxCtx > 0 ? maxCtx : args.budget.fallback ?? DEFAULT_UNKNOWN_CONTEXT_FALLBACK); + if (maxCtx > 0 && args.usedContextTokens !== undefined) { + cap = Math.min(cap, maxCtx - args.usedContextTokens); + } return Math.max(MIN_FLOOR, cap); } @@ -79,12 +84,14 @@ export function applyCompletionBudget(args: { readonly provider: ChatProvider; readonly budget: CompletionBudgetConfig | undefined; readonly capability: ModelCapability | undefined; + readonly usedContextTokens?: number; }): ChatProvider { if (args.budget === undefined) return args.provider; if (args.provider.withMaxCompletionTokens === undefined) return args.provider; const cap = computeCompletionBudgetCap({ budget: args.budget, capability: args.capability, + usedContextTokens: args.usedContextTokens, }); return args.provider.withMaxCompletionTokens(cap); } diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 9b3e56da..072b97bf 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1841,6 +1841,86 @@ describe('FullCompaction', () => { expect(compactionMaxCompletionTokens).toEqual([undefined]); }); + it('uses default 128k hardCap when maxOutputSize is not configured', async () => { + let callCount = 0; + const compactionMaxCompletionTokens: unknown[] = []; + const generate: GenerateFn = async (provider, _system, _tools, _history, callbacks) => { + callCount += 1; + if (callCount === 1) { + throw new APIContextOverflowError(400, 'Context length exceeded', 'req-default-cap'); + } + if (callCount === 2) { + compactionMaxCompletionTokens.push(providerMaxCompletionTokens(provider)); + return textResult('Default cap compacted summary.'); + } + await callbacks?.onMessagePart?.({ + type: 'text', + text: 'Recovered with default cap.', + }); + return textResult('Recovered with default cap.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with default cap' }] }); + await ctx.untilTurnEnd(); + + expect(callCount).toBe(3); + expect(compactionMaxCompletionTokens).toEqual([128 * 1024]); + }); + + it('shrinks the compaction max_tokens to the remaining context window', async () => { + let callCount = 0; + const compactionMaxCompletionTokens: unknown[] = []; + const generate: GenerateFn = async (provider, _system, _tools, _history, callbacks) => { + callCount += 1; + if (callCount === 1) { + throw new APIContextOverflowError(400, 'Context length exceeded', 'req-remaining-window'); + } + if (callCount === 2) { + compactionMaxCompletionTokens.push(providerMaxCompletionTokens(provider)); + return textResult('Remaining-window compacted summary.'); + } + await callbacks?.onMessagePart?.({ + type: 'text', + text: 'Recovered within remaining window.', + }); + return textResult('Recovered within remaining window.'); + }; + const maxContextTokens = 4_000; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: maxContextTokens }, + }); + // ~2000 estimated tokens of history so the remaining window is well + // below the flat min(maxCtx, 128k) cap the budget would otherwise use. + ctx.appendExchange(1, 'x'.repeat(4_000), 'y'.repeat(4_000), 20); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry near the window top' }] }); + await ctx.untilTurnEnd(); + + expect(callCount).toBe(3); + expect(compactionMaxCompletionTokens).toHaveLength(1); + const cap = compactionMaxCompletionTokens[0]; + if (typeof cap !== 'number') { + throw new TypeError(`expected a numeric max_completion_tokens, got ${String(cap)}`); + } + // The 8000 ASCII history chars estimate to >= 2000 tokens (~4 chars per + // token), so the remaining-window cap must land at or below + // maxContextTokens - 2000 — well under the flat min(maxCtx, 128k) the + // budget used before the fix. The exact value tracks the estimator and + // message-projection internals, so bound it instead of pinning it. + expect(cap).toBeGreaterThan(1); + expect(cap).toBeLessThanOrEqual(maxContextTokens - 2000); + }); + it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => { let callCount = 0; const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 218239f3..eddeb20a 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -74,7 +74,7 @@ describe('ConfigState model capabilities', () => { }); }); - it('uses model max output size as the LLM completion cap', async () => { + it('clamps the LLM completion cap to 128k for openai-compatible providers', async () => { let requestMaxTokens: unknown; const ctx = testAgent({ generate: async (provider) => { @@ -121,7 +121,9 @@ describe('ConfigState model capabilities', () => { signal: new AbortController().signal, }); - expect(requestMaxTokens).toBe(384000); + // maxOutputSize (384000) is clamped to the 128k ceiling applied to + // OpenAI-compatible chat-completions providers. + expect(requestMaxTokens).toBe(131072); }); it('uses session id as a provider prompt cache hint without storing it on Agent', () => { diff --git a/packages/agent-core/test/utils/completion-budget.test.ts b/packages/agent-core/test/utils/completion-budget.test.ts index a119344f..9c6e3f21 100644 --- a/packages/agent-core/test/utils/completion-budget.test.ts +++ b/packages/agent-core/test/utils/completion-budget.test.ts @@ -82,6 +82,42 @@ describe('computeCompletionBudgetCap', () => { }); expect(cap).toBe(1024); }); + + it('shrinks the cap to the remaining context window when usage is known', () => { + const cap = computeCompletionBudgetCap({ + budget: { hardCap: 128 * 1024 }, + capability: makeCapability(131072), + usedContextTokens: 120000, + }); + expect(cap).toBe(131072 - 120000); + }); + + it('shrinks the context-window default to the remaining window', () => { + const cap = computeCompletionBudgetCap({ + budget: { fallback: 32000 }, + capability: makeCapability(100000), + usedContextTokens: 30000, + }); + expect(cap).toBe(70000); + }); + + it('floors at 1 when the context window is already exhausted', () => { + const cap = computeCompletionBudgetCap({ + budget: { hardCap: 8192 }, + capability: makeCapability(100000), + usedContextTokens: 100001, + }); + expect(cap).toBe(1); + }); + + it('ignores usedContextTokens when the context window is unknown', () => { + const cap = computeCompletionBudgetCap({ + budget: { fallback: 8192 }, + capability: undefined, + usedContextTokens: 999999, + }); + expect(cap).toBe(8192); + }); }); describe('applyCompletionBudget', () => { @@ -150,6 +186,16 @@ describe('applyCompletionBudget', () => { expect(withMaxCompletionTokens.mock.calls[0]?.[0]).toBe(8192); expect(result).not.toBe(original); }); + + it('shrinks the cap to the remaining context window', () => { + applyCompletionBudget({ + provider: original, + budget: { hardCap: 8192 }, + capability: makeCapability(10000), + usedContextTokens: 9000, + }); + expect(withMaxCompletionTokens).toHaveBeenCalledExactlyOnceWith(1000); + }); }); describe('resolveCompletionBudget', () => { diff --git a/packages/kosong/src/provider.ts b/packages/kosong/src/provider.ts index bf7d4b79..28187a63 100644 --- a/packages/kosong/src/provider.ts +++ b/packages/kosong/src/provider.ts @@ -159,7 +159,9 @@ export interface ChatProvider { /** * Return a shallow copy of this provider with the per-request completion * budget clamped to `maxCompletionTokens`. Optional because not every - * backend benefits from a client-computed cap. + * backend benefits from a client-computed cap. The caller is responsible + * for sizing the cap to the remaining context window; implementations may + * further tighten it to their own transport ceilings. * * Implementations MUST NOT mutate or replace internal HTTP clients on the * returned clone — the clone is expected to share transport state with the diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 2bf89eea..44004440 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -48,6 +48,13 @@ import { // arms can be overridden by an explicit `reasoningKey` on the provider config. const KNOWN_REASONING_KEYS = ['reasoning_content', 'reasoning_details', 'reasoning'] as const; const DEFAULT_OUTBOUND_REASONING_KEY = KNOWN_REASONING_KEYS[0]; + +/** + * Hard upper bound on `max_tokens` for OpenAI-compatible chat-completions + * endpoints. Many third-party providers reject `max_tokens` above this limit + * (the documented range is `[1, 131072]`). + */ +const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, @@ -646,7 +653,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { } withMaxCompletionTokens(maxCompletionTokens: number): OpenAILegacyChatProvider { - return this.withGenerationKwargs(completionTokenKwargs(this._model, maxCompletionTokens)); + const cap = Math.min(maxCompletionTokens, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); + return this.withGenerationKwargs(completionTokenKwargs(this._model, Math.max(1, cap))); } private _clone(): OpenAILegacyChatProvider { diff --git a/packages/kosong/src/providers/pythinker.ts b/packages/kosong/src/providers/pythinker.ts index 314404b3..f0cf89ee 100644 --- a/packages/kosong/src/providers/pythinker.ts +++ b/packages/kosong/src/providers/pythinker.ts @@ -552,7 +552,7 @@ export class PythinkerChatProvider implements ChatProvider { } withMaxCompletionTokens(maxCompletionTokens: number): PythinkerChatProvider { - return this._withGenerationKwargs({ max_completion_tokens: maxCompletionTokens }); + return this._withGenerationKwargs({ max_completion_tokens: Math.max(1, maxCompletionTokens) }); } withExtraBody(extraBody: ExtraBody): PythinkerChatProvider { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 0a619f11..350c689b 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -643,6 +643,16 @@ describe('OpenAILegacyChatProvider', () => { expect(body['max_tokens']).toBe(1024); expect(body['max_completion_tokens']).toBeUndefined(); }); + + it('withMaxCompletionTokens clamps to the 128k ceiling', async () => { + const provider = createProvider().withMaxCompletionTokens(1000000); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['max_tokens']).toBe(131072); + }); }); describe('fast mode', () => {