From 63ee849b730441990f99f5b34b0aed86357ee08e Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 01:29:03 -0400 Subject: [PATCH 1/6] fix: keep Dynamic Workflow progress moving instead of pinning at 90% Per-agent progress previously jumped through fixed stage values and capped at 90 for the entire finalizing stream. Each model delta now creeps toward the stage ceiling with a minimum step, the aggregate line shows Finalizing once every delegated agent is terminal but the result has not arrived, and narrow-width member rows pad the state token so the task column no longer shifts between phases. --- .changeset/dynamic-workflow-progress-stall.md | 5 + .../dynamic-workflow-mission-control.ts | 62 +++++++-- .../src/tui/constant/rendering.ts | 9 +- .../dynamic-workflow-mission-control.test.ts | 124 +++++++++++++++++- 4 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 .changeset/dynamic-workflow-progress-stall.md 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/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/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); + }); }); From 41a660d76c0bce331badedb8103cc18a4a2f47c4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 01:30:01 -0400 Subject: [PATCH 2/6] fix: cap compaction and completion max_tokens to provider limits Compaction requests sent max_tokens equal to the model's full context window whenever maxOutputSize was not configured, which strict OpenAI-compatible providers reject with 400 invalid_request_error (e.g. "Invalid max_tokens value, the valid range of max_tokens is [1, 393216]"). Cap compaction output at 128k by default and size chat-completions caps to the remaining context window. --- .changeset/compaction-max-tokens-cap.md | 5 +++ .../agent-core/src/agent/compaction/full.ts | 24 +++++++++++++- packages/agent-core/src/agent/index.ts | 1 + .../agent-core/src/agent/turn/kosong-llm.ts | 9 +++++ .../agent-core/src/utils/completion-budget.ts | 6 +++- .../test/agent/compaction/full.test.ts | 33 +++++++++++++++++++ .../test/agent/config-state.test.ts | 6 ++-- packages/kosong/src/provider.ts | 26 ++++++++++++++- .../kosong/src/providers/openai-legacy.ts | 24 ++++++++++++-- packages/kosong/src/providers/pythinker.ts | 16 +++++++-- packages/kosong/test/openai-legacy.test.ts | 14 ++++++++ packages/kosong/test/pythinker.test.ts | 13 ++++++++ 12 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 .changeset/compaction-max-tokens-cap.md 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/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index bc1c55b1..9bcc4dc7 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,25 @@ 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, }); 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..3f9d5a3b 100644 --- a/packages/agent-core/src/utils/completion-budget.ts +++ b/packages/agent-core/src/utils/completion-budget.ts @@ -79,6 +79,7 @@ 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; @@ -86,5 +87,8 @@ export function applyCompletionBudget(args: { budget: args.budget, capability: args.capability, }); - return args.provider.withMaxCompletionTokens(cap); + return args.provider.withMaxCompletionTokens(cap, { + usedContextTokens: args.usedContextTokens, + maxContextTokens: args.capability?.max_context_tokens, + }); } diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 9b3e56da..f6f007ac 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1841,6 +1841,39 @@ 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('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/kosong/src/provider.ts b/packages/kosong/src/provider.ts index bf7d4b79..d6bb4814 100644 --- a/packages/kosong/src/provider.ts +++ b/packages/kosong/src/provider.ts @@ -13,6 +13,22 @@ import type { TokenUsage } from './usage'; */ export type ThinkingEffort = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +/** + * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a + * provider can tighten the caller-supplied cap to its own transport + * constraints. + */ +export interface MaxCompletionTokensOptions { + /** + * Tokens already consumed by the current context (API-reported input + + * output of the latest completed step). Chat-completions providers use it + * to size the cap to the remaining context window. + */ + readonly usedContextTokens?: number; + /** Model context-window size in tokens (`max_context_size`). */ + readonly maxContextTokens?: number; +} + /** * Normalized finish-reason signal indicating why a generation stopped. * @@ -161,11 +177,19 @@ export interface ChatProvider { * budget clamped to `maxCompletionTokens`. Optional because not every * backend benefits from a client-computed cap. * + * When `options` are provided, implementations may further tighten the cap + * based on their own transport constraints — e.g. chat-completions + * endpoints size the cap to the remaining context window + * (`maxContextTokens - usedContextTokens`) and/or clamp to a fixed ceiling. + * * Implementations MUST NOT mutate or replace internal HTTP clients on the * returned clone — the clone is expected to share transport state with the * original. See `PythinkerChatProvider._clone()` for the rationale. */ - withMaxCompletionTokens?(maxCompletionTokens: number): ChatProvider; + withMaxCompletionTokens?( + maxCompletionTokens: number, + options?: MaxCompletionTokensOptions, + ): ChatProvider; /** Upload a video and return a content part that can be sent to this provider. */ uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise; } diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 2bf89eea..ae563053 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -3,6 +3,7 @@ import type { ChatProvider, FinishReason, GenerateOptions, + MaxCompletionTokensOptions, ProviderRequestAuth, StreamedMessage, ThinkingEffort, @@ -48,6 +49,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, @@ -645,8 +653,20 @@ export class OpenAILegacyChatProvider implements ChatProvider { return clone; } - withMaxCompletionTokens(maxCompletionTokens: number): OpenAILegacyChatProvider { - return this.withGenerationKwargs(completionTokenKwargs(this._model, maxCompletionTokens)); + withMaxCompletionTokens( + maxCompletionTokens: number, + options?: MaxCompletionTokensOptions, + ): OpenAILegacyChatProvider { + let cap = maxCompletionTokens; + if ( + options?.usedContextTokens !== undefined && + options?.maxContextTokens !== undefined && + options.maxContextTokens > 0 + ) { + cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); + } + cap = Math.min(cap, 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..47f22484 100644 --- a/packages/kosong/src/providers/pythinker.ts +++ b/packages/kosong/src/providers/pythinker.ts @@ -4,6 +4,7 @@ import type { ChatProvider, FinishReason, GenerateOptions, + MaxCompletionTokensOptions, ProviderRequestAuth, StreamedMessage, ThinkingEffort, @@ -551,8 +552,19 @@ export class PythinkerChatProvider implements ChatProvider { return this._withGenerationKwargs(kwargs); } - withMaxCompletionTokens(maxCompletionTokens: number): PythinkerChatProvider { - return this._withGenerationKwargs({ max_completion_tokens: maxCompletionTokens }); + withMaxCompletionTokens( + maxCompletionTokens: number, + options?: MaxCompletionTokensOptions, + ): PythinkerChatProvider { + let cap = maxCompletionTokens; + if ( + options?.usedContextTokens !== undefined && + options?.maxContextTokens !== undefined && + options.maxContextTokens > 0 + ) { + cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); + } + return this._withGenerationKwargs({ max_completion_tokens: Math.max(1, cap) }); } withExtraBody(extraBody: ExtraBody): PythinkerChatProvider { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 0a619f11..ed2e1e3a 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -643,6 +643,20 @@ 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, { + usedContextTokens: 30000, + maxContextTokens: 1000000, + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + // 1000000 - 30000 = 970000, clamped to 131072 + expect(body['max_tokens']).toBe(131072); + }); }); describe('fast mode', () => { diff --git a/packages/kosong/test/pythinker.test.ts b/packages/kosong/test/pythinker.test.ts index 332f0e6b..f19b8310 100644 --- a/packages/kosong/test/pythinker.test.ts +++ b/packages/kosong/test/pythinker.test.ts @@ -646,6 +646,19 @@ describe('PythinkerChatProvider', () => { expect(body['max_tokens']).toBeUndefined(); }); + it('withMaxCompletionTokens sizes the cap to the remaining context window', async () => { + const provider = createProvider().withMaxCompletionTokens(100000, { + usedContextTokens: 30000, + maxContextTokens: 100000, + }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['max_completion_tokens']).toBe(70000); + }); + it('passes constructor generation kwargs into the request body', async () => { const provider = new PythinkerChatProvider({ model: 'pythinker-k2-turbo-preview', From 2bb9e9d4eb000b072633268b16e8c55d4cc9f4e2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 01:30:06 -0400 Subject: [PATCH 3/6] feat: restart Homebrew-managed installs after update and refine update preflight Add Homebrew install detection with an update helper and activation step so brew-managed CLIs restart onto the new version after /update, and update the doctor, preflight, and docs accordingly. --- .changeset/homebrew-restart-updates.md | 5 + apps/pythinker-code/src/cli/sub/doctor.ts | 88 +++- .../src/cli/update/activation.ts | 169 ++++++++ .../pythinker-code/src/cli/update/homebrew.ts | 328 +++++++++++++++ .../src/cli/update/install-lock.ts | 28 +- .../src/cli/update/install-state.ts | 23 ++ .../src/cli/update/preflight.ts | 235 +++++++++-- apps/pythinker-code/src/cli/update/types.ts | 22 + .../src/cli/update/update-helper.ts | 136 +++++++ apps/pythinker-code/src/constant/app.ts | 1 + apps/pythinker-code/src/main.ts | 51 ++- apps/pythinker-code/src/tui/commands/info.ts | 15 +- .../dialogs/update-preference-selector.ts | 2 +- apps/pythinker-code/src/utils/paths.ts | 8 + apps/pythinker-code/src/utils/persistence.ts | 21 +- apps/pythinker-code/test/cli/doctor.test.ts | 63 ++- apps/pythinker-code/test/cli/main.test.ts | 102 +++++ .../test/cli/update/activation.test.ts | 384 ++++++++++++++++++ .../test/cli/update/cache.test.ts | 20 +- .../test/cli/update/preflight.test.ts | 62 ++- .../test/cli/update/update-helper.test.ts | 280 +++++++++++++ .../components/dialogs/choice-picker.test.ts | 4 +- docs/configuration/config-files.md | 2 +- docs/configuration/data-locations.md | 5 +- docs/configuration/env-vars.md | 2 +- docs/guides/getting-started.md | 2 +- docs/reference/pythinker-command.md | 4 +- 27 files changed, 1992 insertions(+), 70 deletions(-) create mode 100644 .changeset/homebrew-restart-updates.md create mode 100644 apps/pythinker-code/src/cli/update/activation.ts create mode 100644 apps/pythinker-code/src/cli/update/homebrew.ts create mode 100644 apps/pythinker-code/src/cli/update/update-helper.ts create mode 100644 apps/pythinker-code/test/cli/update/activation.test.ts create mode 100644 apps/pythinker-code/test/cli/update/update-helper.test.ts 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..e1358450 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,53 @@ 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']; + } + 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..e3eecb04 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/activation.ts @@ -0,0 +1,169 @@ +import { gte, valid } from 'semver'; + +import { getUpdateInstallLogFile } from '#/utils/paths'; + +import { + activateHomebrewUpdate, + PreparedHomebrewUpdateInvalidError, +} from './homebrew'; +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 errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +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 (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 (await deps.detectSource() !== pending.source) { + await deps.writeState({ ...state, active: null, pending: null }); + return { status: 'invalidated' as const, version: pending.version }; + } + + if (activationAttempts(state, pending.version) >= ACTIVATION_FAILURE_LIMIT) { + 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: 'homebrew', + operation: 'activate', + jobId: pending.jobId, + startedAt, + pid: deps.pid, + }, + }; + await deps.writeState(activatingState); + + try { + const activated = await deps.activateHomebrew(pending); + return { + status: 'activated' as const, + version: activated.version, + executable: activated.executable, + }; + } catch (error) { + const message = errorMessage(error); + if (error instanceof PreparedHomebrewUpdateInvalidError) { + await deps.writeState({ + ...activatingState, + active: null, + pending: null, + lastFailure: { + version: pending.version, + failedAt: deps.now().toISOString(), + attempts: 1, + 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/homebrew.ts b/apps/pythinker-code/src/cli/update/homebrew.ts new file mode 100644 index 00000000..92780842 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/homebrew.ts @@ -0,0 +1,328 @@ +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; + logWrites = logWrites + .then(async () => { + if (typeof chunk === 'string') await logFile.write(chunk); + else await logFile.write(chunk); + }) + .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..1fec0130 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.string().uuid().optional(), }) .strict() .nullable(), + pending: z + .object({ + jobId: z.string().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(), diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index e9d467e8..c37e1c7b 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'; @@ -38,6 +42,7 @@ import { type UpdateCache, type UpdateManifest, type UpdatePreflightResult, + type UpdateRequestOrigin, type UpdateTarget, } from './types'; @@ -55,6 +60,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 +95,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 +105,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 +115,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[]; @@ -170,8 +186,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 ` + @@ -537,6 +552,124 @@ 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: string, + 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; + + 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; + } + + const jobId = randomUUID(); + const startedState: UpdateInstallState = { + ...freshState, + active: { + version: target.version, + source: 'homebrew', + operation: 'prepare', + jobId, + startedAt: nowIso(), + }, + pending: null, + }; + await writeUpdateInstallState(startedState); + + 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', + }); + try { + await waitForChildSpawn(child); + } catch (error) { + const attempts = failureAttemptsFor(startedState, target) + 1; + await writeUpdateInstallState({ + ...startedState, + active: null, + lastFailure: { + version: target.version, + failedAt: nowIso(), + attempts, + operation: 'prepare', + message: formatErrorMessage(error), + }, + }).catch(() => {}); + throw error; + } + child.unref(); + + 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, + }); + } finally { + await lock.release().catch(() => {}); + } +} + async function startBackgroundInstall( state: UpdateInstallState, currentVersion: string, @@ -689,13 +822,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 +881,8 @@ 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 status: 'manual'; readonly version: string; @@ -733,8 +893,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 +912,27 @@ 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', + }; + } + if ( + source === 'homebrew' && + installState.pending !== null && + preparedVersionCoversTarget(installState.pending.version, target.version) + ) { + return { + status: 'in-progress', + version: installState.pending.version, + installOnRestart: 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,6 +943,32 @@ export async function startManualUpdate( } try { + const rolloutTelemetry = rolloutTelemetryFor( + resolveUpdateDeviceId(), + target.version, + cache.manifest, + true, + ); + if (source === 'homebrew') { + await startBackgroundHomebrewPreparation( + installState, + currentVersion, + target, + 'manual', + undefined, + logger, + rolloutTelemetry, + ); + 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, + }; + } await startBackgroundInstall( installState, currentVersion, @@ -788,9 +977,9 @@ export async function startManualUpdate( platform, undefined, logger, - rolloutTelemetryFor(resolveUpdateDeviceId(), target.version, cache.manifest, true), + rolloutTelemetry, ); - return { status: 'started', version: target.version }; + 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..76ec7943 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/update-helper.ts @@ -0,0 +1,136 @@ +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 { prepareHomebrewUpdate } from './homebrew'; +import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import type { UpdateInstallState } from './types'; + +const UPDATE_INSTALL_LOG_MAX_BYTES = 1024 * 1024; + +const PrepareHomebrewArgsSchema = z.tuple([ + z.literal('prepare-homebrew'), + z.string().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 errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +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: ${error instanceof Error ? error.message : String(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 (state.active?.jobId !== jobId || state.active.operation !== 'prepare') { + await appendHelperLog(`prepare job ${jobId} is no longer active`); + return 0; + } + + state = { + ...state, + active: { + ...state.active, + pid: process.pid, + }, + }; + await writeUpdateInstallState(state); + await appendHelperLog(`prepare job ${jobId} started for ${requestedVersion}`); + + try { + const prepared = await prepareHomebrewUpdate( + { jobId, requestedVersion, requestedBy }, + { logFile: getUpdateInstallLogFile() }, + ); + const latest = await readUpdateInstallState(); + if (latest.active?.jobId !== jobId || latest.active.operation !== 'prepare') { + await appendHelperLog(`prepare job ${jobId} lost ownership before completion`); + return 0; + } + await writeUpdateInstallState({ + ...latest, + active: null, + pending: prepared, + lastFailure: null, + }); + await appendHelperLog(`prepare job ${jobId} verified ${prepared.version}`); + return 0; + } catch (error) { + const latest = await readUpdateInstallState(); + if (latest.active?.jobId !== jobId) return 1; + const message = errorMessage(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..3ff3c4b0 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,23 @@ 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 + ? 'Once preparation finishes, restart the CLI to install it.' + : '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..76d8daa5 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; Homebrew installs on the next launch.', }, { value: 'off', 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..c472265a 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'; @@ -58,8 +58,25 @@ export async function writeJsonFile( 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'); + await file.sync(); + } finally { + await file.close(); + } await rename(tmpPath, filePath); + // 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 durability 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..22856ef1 --- /dev/null +++ b/apps/pythinker-code/test/cli/update/activation.test.ts @@ -0,0 +1,384 @@ +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).toHaveBeenLastCalledWith(expect.objectContaining({ + pending, + active: expect.objectContaining({ + version: '0.5.0', + source: 'homebrew', + operation: 'activate', + jobId: pending.jobId, + pid: 42_424, + }), + lastSuccess: null, + })); + expect(release).toHaveBeenCalledOnce(); + }); + + 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, + 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.4.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 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..b44ab8bf 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -53,6 +53,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,6 +162,7 @@ function releasedForEveryone(version: string): UpdateManifest { function installState(overrides: Partial = {}): UpdateInstallState { return { active: null, + pending: null, lastFailure: null, lastSuccess: null, ...overrides, @@ -463,15 +465,46 @@ 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, + expect.arrayContaining([ + '__update_helper', + 'prepare-homebrew', + '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 +1499,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 +1513,7 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'started', version: '0.5.0', + installOnRestart: false, }); }); @@ -1491,20 +1526,30 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'started', version: '0.5.0', + installOnRestart: false, }); }); - it('returns the manual command when the source cannot auto-install', async () => { + 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: 'manual', + status: 'started', version: '0.5.0', - command: 'brew upgrade pythinker-code', - source: 'homebrew', + installOnRestart: true, }); - expect(mocks.spawn).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(['prepare-homebrew', '0.5.0', 'manual']), + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(mocks.spawn).toHaveBeenCalledOnce(); }); it('reports an install already in progress instead of double-starting', async () => { @@ -1517,6 +1562,7 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'in-progress', version: '0.5.0', + installOnRestart: 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..a4d3b2a0 --- /dev/null +++ b/apps/pythinker-code/test/cli/update/update-helper.test.ts @@ -0,0 +1,280 @@ +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('../../../src/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('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, + })).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..be634ce4 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; Homebrew installs on the next launch.', + ); }); it('routes Space into the query for searchable lists instead of selecting', () => { 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..44e59953 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 and present the update command for your installation method. 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` From 1ea55430cbc1f4699fa5f598fec0d5d376a8840e Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 01:58:09 -0400 Subject: [PATCH 4/6] fix: address code review findings on update lifecycle and token caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centralize the remaining-context-window clamp in computeCompletionBudgetCap so every provider benefits, and pass usedContextTokens from compaction — the near-full request the cap exists for. Remove the per-provider MaxCompletionTokensOptions duplication from kosong. - Clear the pending update record once the activation failure limit is reached so startup and /update stop reporting a dead update forever. - Keep lastFailure across a successful prepare so invalid-artifact activations accumulate toward the failure threshold. - Retain a verified pending update while a newer preparation runs. - Scope failure-attempt increments by operation; record install failures with operation: 'install'. - Return whether background prepare/install actually started and map a refused start to 'in-progress' instead of claiming 'started'. - Make writeJsonFile fsync opt-in ({ durable: true }, install.json only). - Dedupe formatErrorMessage into cli/update/format-error.ts; normalize appendLog chunks instead of duplicated branches; z.uuid(); source-neutral update-preference wording; align pythinker upgrade docs; skip the POSIX-only detached-helper test on Windows; exact helper spawn asserts. --- .../src/cli/update/activation.ts | 36 ++++-- .../src/cli/update/format-error.ts | 4 + .../pythinker-code/src/cli/update/homebrew.ts | 6 +- .../src/cli/update/install-state.ts | 6 +- .../src/cli/update/preflight.ts | 121 +++++++++++++----- .../src/cli/update/update-helper.ts | 55 ++++---- .../dialogs/update-preference-selector.ts | 2 +- apps/pythinker-code/src/utils/persistence.ts | 30 +++-- .../test/cli/update/activation.test.ts | 68 +++++++++- .../test/cli/update/preflight.test.ts | 69 +++++++++- .../test/cli/update/update-helper.test.ts | 32 ++++- .../components/dialogs/choice-picker.test.ts | 2 +- docs/guides/getting-started.md | 2 +- .../agent-core/src/agent/compaction/full.ts | 4 + .../agent-core/src/utils/completion-budget.ts | 17 ++- .../test/agent/compaction/full.test.ts | 39 ++++++ .../test/utils/completion-budget.test.ts | 46 +++++++ packages/kosong/src/provider.ts | 30 +---- .../kosong/src/providers/openai-legacy.ts | 16 +-- packages/kosong/src/providers/pythinker.ts | 16 +-- packages/kosong/test/openai-legacy.test.ts | 6 +- packages/kosong/test/pythinker.test.ts | 13 -- 22 files changed, 449 insertions(+), 171 deletions(-) create mode 100644 apps/pythinker-code/src/cli/update/format-error.ts diff --git a/apps/pythinker-code/src/cli/update/activation.ts b/apps/pythinker-code/src/cli/update/activation.ts index e3eecb04..feef7428 100644 --- a/apps/pythinker-code/src/cli/update/activation.ts +++ b/apps/pythinker-code/src/cli/update/activation.ts @@ -6,6 +6,7 @@ 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'; @@ -52,10 +53,6 @@ function activationAttempts(state: UpdateInstallState, version: string): number return failure?.version === version && failure.operation === 'activate' ? failure.attempts : 0; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function isRunningPreparedVersion(currentVersion: string, preparedVersion: string): boolean { return ( valid(currentVersion) !== null && @@ -77,6 +74,11 @@ export async function activatePendingUpdate( 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({ @@ -92,12 +94,10 @@ export async function activatePendingUpdate( return { status: 'finalized' as const, version: currentVersion }; } - if (await deps.detectSource() !== pending.source) { - await deps.writeState({ ...state, active: null, pending: null }); - return { status: 'invalidated' as const, version: pending.version }; - } - 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, @@ -116,7 +116,7 @@ export async function activatePendingUpdate( ...state, active: { version: pending.version, - source: 'homebrew', + source: pending.source, operation: 'activate', jobId: pending.jobId, startedAt, @@ -127,14 +127,26 @@ export async function activatePendingUpdate( 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 = errorMessage(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, @@ -142,7 +154,7 @@ export async function activatePendingUpdate( lastFailure: { version: pending.version, failedAt: deps.now().toISOString(), - attempts: 1, + attempts: prepareAttempts, operation: 'prepare', message, }, 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 index 92780842..866cc2c2 100644 --- a/apps/pythinker-code/src/cli/update/homebrew.ts +++ b/apps/pythinker-code/src/cli/update/homebrew.ts @@ -107,10 +107,12 @@ export async function runHomebrewCommand( 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 () => { - if (typeof chunk === 'string') await logFile.write(chunk); - else await logFile.write(chunk); + await logFile.write(data); }) .catch(() => {}); }; diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index 1fec0130..c34ce297 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -27,13 +27,13 @@ const UpdateInstallStateSchema: z.ZodType = z startedAt: z.string().min(1), pid: z.number().int().positive().optional(), operation: UpdateInstallOperationSchema.optional(), - jobId: z.string().uuid().optional(), + jobId: z.uuid().optional(), }) .strict() .nullable(), pending: z .object({ - jobId: z.string().uuid(), + jobId: z.uuid(), source: z.literal('homebrew'), version: z.string().min(1), preparedAt: z.string().min(1), @@ -84,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 c37e1c7b..465760e2 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -14,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 { @@ -38,6 +39,7 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, + type UpdateInstallOperation, type UpdateInstallState, type UpdateCache, type UpdateManifest, @@ -156,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, @@ -351,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 { @@ -569,7 +584,7 @@ async function waitForChildSpawn(child: ReturnType): Promise } function updateHelperCommand( - operation: string, + operation: 'prepare-homebrew', jobId: string, version: string, requestedBy: UpdateRequestOrigin, @@ -594,9 +609,9 @@ async function startBackgroundHomebrewPreparation( 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; try { const freshState = await readUpdateInstallState().catch(() => state); @@ -605,10 +620,12 @@ async function startBackgroundHomebrewPreparation( (freshState.pending !== null && preparedVersionCoversTarget(freshState.pending.version, target.version)) || failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD ) { - return; + 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: { @@ -618,26 +635,26 @@ async function startBackgroundHomebrewPreparation( jobId, startedAt: nowIso(), }, - pending: null, }; await writeUpdateInstallState(startedState); - 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', - }); 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) + 1; + const attempts = failureAttemptsFor(startedState, target, 'prepare') + 1; await writeUpdateInstallState({ ...startedState, active: null, @@ -651,7 +668,6 @@ async function startBackgroundHomebrewPreparation( }).catch(() => {}); throw error; } - child.unref(); trackUpdateEvent(track, 'update_background_prepare_started', { current_version: currentVersion, @@ -665,6 +681,7 @@ async function startBackgroundHomebrewPreparation( source: 'homebrew', jobId, }); + return true; } finally { await lock.release().catch(() => {}); } @@ -679,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 { @@ -690,7 +707,7 @@ async function startBackgroundInstall( hasFreshActiveInstall(freshState) || failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD ) { - return; + return false; } let startedState: UpdateInstallState = { @@ -728,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 ? { @@ -748,6 +765,7 @@ async function startBackgroundInstall( version: target.version, failedAt: nowIso(), attempts, + operation: 'install', }, }; try { @@ -803,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 { @@ -882,7 +901,12 @@ export type ManualUpdateResult = | { readonly status: 'up-to-date' } | { readonly status: 'check-failed'; readonly message: string } | { readonly status: 'started'; readonly version: string; readonly installOnRestart: boolean } - | { readonly status: 'in-progress'; 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; @@ -918,6 +942,7 @@ export async function startManualUpdate( status: 'in-progress', version: installState.active?.version ?? target.version, installOnRestart: installState.active?.source === 'homebrew', + readyToInstall: false, }; } if ( @@ -925,10 +950,22 @@ export async function startManualUpdate( 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: installState.pending.version, + version: pending.version, installOnRestart: true, + readyToInstall: true, }; } // Repeated background failures fall back to the copyable command instead of @@ -950,7 +987,7 @@ export async function startManualUpdate( true, ); if (source === 'homebrew') { - await startBackgroundHomebrewPreparation( + const started = await startBackgroundHomebrewPreparation( installState, currentVersion, target, @@ -959,6 +996,16 @@ export async function startManualUpdate( 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)) { @@ -969,7 +1016,7 @@ export async function startManualUpdate( source, }; } - await startBackgroundInstall( + const started = await startBackgroundInstall( installState, currentVersion, target, @@ -979,6 +1026,14 @@ export async function startManualUpdate( logger, rolloutTelemetry, ); + 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/update-helper.ts b/apps/pythinker-code/src/cli/update/update-helper.ts index 76ec7943..a373972c 100644 --- a/apps/pythinker-code/src/cli/update/update-helper.ts +++ b/apps/pythinker-code/src/cli/update/update-helper.ts @@ -6,15 +6,16 @@ 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 { UpdateInstallState } from './types'; +import type { UpdateInstallActive, UpdateInstallState } from './types'; const UPDATE_INSTALL_LOG_MAX_BYTES = 1024 * 1024; const PrepareHomebrewArgsSchema = z.tuple([ z.literal('prepare-homebrew'), - z.string().uuid(), + z.uuid(), z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), z.enum(['automatic', 'manual']), ]); @@ -48,8 +49,18 @@ function prepareFailureAttempts(state: UpdateInstallState, version: string): num return failure?.version === version && failure.operation === 'prepare' ? failure.attempts : 0; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +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 { @@ -65,9 +76,7 @@ export function dispatchUpdateHelperIfRequested(): boolean { process.exitCode = code; }) .catch((error: unknown) => { - process.stderr.write( - `Update helper failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); + process.stderr.write(`Update helper failed: ${formatErrorMessage(error)}\n`); process.exitCode = 1; }); return true; @@ -82,43 +91,45 @@ export async function runUpdateHelper(args: readonly string[]): Promise const [, jobId, requestedVersion, requestedBy] = parsed.data; await rotateHelperLogIfNeeded(); let state = await readUpdateInstallState(); - if (state.active?.jobId !== jobId || state.active.operation !== 'prepare') { + if (!ownsPrepareJob(state, jobId, requestedVersion)) { await appendHelperLog(`prepare job ${jobId} is no longer active`); return 0; } - state = { - ...state, - active: { - ...state.active, - pid: process.pid, - }, - }; - await writeUpdateInstallState(state); - await appendHelperLog(`prepare job ${jobId} started for ${requestedVersion}`); - 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 (latest.active?.jobId !== jobId || latest.active.operation !== 'prepare') { + 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, - lastFailure: null, }); await appendHelperLog(`prepare job ${jobId} verified ${prepared.version}`); return 0; } catch (error) { const latest = await readUpdateInstallState(); - if (latest.active?.jobId !== jobId) return 1; - const message = errorMessage(error); + if (!ownsPrepareJob(latest, jobId, requestedVersion)) return 1; + const message = formatErrorMessage(error); await writeUpdateInstallState({ ...latest, active: null, 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 76d8daa5..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: 'Update automatically; Homebrew installs on the next launch.', + description: 'Update automatically in the background.', }, { value: 'off', diff --git a/apps/pythinker-code/src/utils/persistence.ts b/apps/pythinker-code/src/utils/persistence.ts index c472265a..76d801c0 100644 --- a/apps/pythinker-code/src/utils/persistence.ts +++ b/apps/pythinker-code/src/utils/persistence.ts @@ -52,6 +52,14 @@ 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); @@ -61,20 +69,22 @@ export async function writeJsonFile( const file = await open(tmpPath, 'wx', 0o600); try { await file.writeFile(`${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); - await file.sync(); + if (options?.durable === true) await file.sync(); } finally { await file.close(); } await rename(tmpPath, filePath); - // 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 durability step is unavailable. - const directory = await open(dirname(filePath), 'r').catch(() => null); - if (directory !== null) { - try { - await directory.sync().catch(() => {}); - } finally { - await directory.close(); + 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) { diff --git a/apps/pythinker-code/test/cli/update/activation.test.ts b/apps/pythinker-code/test/cli/update/activation.test.ts index 22856ef1..bb4be333 100644 --- a/apps/pythinker-code/test/cli/update/activation.test.ts +++ b/apps/pythinker-code/test/cli/update/activation.test.ts @@ -68,7 +68,7 @@ describe('pending update activation', () => { }); expect(activateHomebrew).toHaveBeenCalledWith(pending); - expect(writeState).toHaveBeenLastCalledWith(expect.objectContaining({ + expect(writeState).toHaveBeenNthCalledWith(1, expect.objectContaining({ pending, active: expect.objectContaining({ version: '0.5.0', @@ -77,11 +77,55 @@ describe('pending update activation', () => { 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({ @@ -103,6 +147,7 @@ describe('pending update activation', () => { 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' }); @@ -124,7 +169,7 @@ describe('pending update activation', () => { const writeState = vi.fn().mockResolvedValue(undefined); const activateHomebrew = vi.fn(); - await expect(activatePendingUpdate('0.4.0', { + await expect(activatePendingUpdate('0.5.0', { enabled: true, automaticEnabled: true, deps: { @@ -363,6 +408,25 @@ describe('Homebrew update adapter', () => { ); }); + 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); diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index b44ab8bf..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, @@ -169,6 +170,21 @@ function installState(overrides: Partial = {}): UpdateInstal }; } +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', @@ -487,12 +503,14 @@ describe('runUpdatePreflight', () => { expect(promptForInstallChoice).not.toHaveBeenCalled(); expect(mocks.spawn).toHaveBeenCalledWith( process.execPath, - expect.arrayContaining([ + [ + 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({ @@ -1546,12 +1564,56 @@ describe('startManualUpdate', () => { }); expect(mocks.spawn).toHaveBeenCalledWith( process.execPath, - expect.arrayContaining(['prepare-homebrew', '0.5.0', 'manual']), + [ + 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('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: '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', + installOnRestart: true, + readyToInstall: true, + }); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + pending: { ...pending, requestedBy: 'manual' }, + })); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + it('reports an install already in progress instead of double-starting', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); @@ -1563,6 +1625,7 @@ describe('startManualUpdate', () => { 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 index a4d3b2a0..d53df6dc 100644 --- a/apps/pythinker-code/test/cli/update/update-helper.test.ts +++ b/apps/pythinker-code/test/cli/update/update-helper.test.ts @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ prepareHomebrewUpdate: vi.fn(), })); -vi.mock('../../../src/cli/update/homebrew', async () => { +vi.mock('#/cli/update/homebrew', async () => { const actual = await vi.importActual( '#/cli/update/homebrew', ); @@ -122,7 +122,34 @@ describe('update helper', () => { expect(mocks.prepareHomebrewUpdate).not.toHaveBeenCalled(); }); - it('finishes preparation in a detached process after its parent exits', async () => { + 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'); @@ -235,6 +262,7 @@ child.once('spawn', () => { child.unref(); closeSync(output); }); 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, 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 be634ce4..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 @@ -154,7 +154,7 @@ describe('ChoicePickerComponent', () => { const upgradePreferenceOutput = upgradePreference.render(120).map(strip); expect(upgradePreferenceOutput).toContain(' ❯ On ← current'); expect(upgradePreferenceOutput).toContain( - ' Update automatically; Homebrew installs on the next launch.', + ' Update automatically in the background.', ); }); diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 44e59953..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**: 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 and present the update command for your installation method. 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/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 9bcc4dc7..0df285e3 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -327,6 +327,10 @@ export class FullCompaction { reservedContextSize: this.agent.pythinkerConfig?.loopControl?.reservedContextSize, }), 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/utils/completion-budget.ts b/packages/agent-core/src/utils/completion-budget.ts index 3f9d5a3b..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); } @@ -86,9 +91,7 @@ export function applyCompletionBudget(args: { const cap = computeCompletionBudgetCap({ budget: args.budget, capability: args.capability, - }); - return args.provider.withMaxCompletionTokens(cap, { usedContextTokens: args.usedContextTokens, - maxContextTokens: args.capability?.max_context_tokens, }); + 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 f6f007ac..b0f17859 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1874,6 +1874,45 @@ describe('FullCompaction', () => { 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] as number; + expect(cap).toBeGreaterThanOrEqual(1); + expect(cap).toBeLessThan(maxContextTokens); + }); + 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/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 d6bb4814..28187a63 100644 --- a/packages/kosong/src/provider.ts +++ b/packages/kosong/src/provider.ts @@ -13,22 +13,6 @@ import type { TokenUsage } from './usage'; */ export type ThinkingEffort = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; -/** - * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a - * provider can tighten the caller-supplied cap to its own transport - * constraints. - */ -export interface MaxCompletionTokensOptions { - /** - * Tokens already consumed by the current context (API-reported input + - * output of the latest completed step). Chat-completions providers use it - * to size the cap to the remaining context window. - */ - readonly usedContextTokens?: number; - /** Model context-window size in tokens (`max_context_size`). */ - readonly maxContextTokens?: number; -} - /** * Normalized finish-reason signal indicating why a generation stopped. * @@ -175,21 +159,15 @@ 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. - * - * When `options` are provided, implementations may further tighten the cap - * based on their own transport constraints — e.g. chat-completions - * endpoints size the cap to the remaining context window - * (`maxContextTokens - usedContextTokens`) and/or clamp to a fixed ceiling. + * 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 * original. See `PythinkerChatProvider._clone()` for the rationale. */ - withMaxCompletionTokens?( - maxCompletionTokens: number, - options?: MaxCompletionTokensOptions, - ): ChatProvider; + withMaxCompletionTokens?(maxCompletionTokens: number): ChatProvider; /** Upload a video and return a content part that can be sent to this provider. */ uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise; } diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index ae563053..44004440 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -3,7 +3,6 @@ import type { ChatProvider, FinishReason, GenerateOptions, - MaxCompletionTokensOptions, ProviderRequestAuth, StreamedMessage, ThinkingEffort, @@ -653,19 +652,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { return clone; } - withMaxCompletionTokens( - maxCompletionTokens: number, - options?: MaxCompletionTokensOptions, - ): OpenAILegacyChatProvider { - let cap = maxCompletionTokens; - if ( - options?.usedContextTokens !== undefined && - options?.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - cap = Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); + withMaxCompletionTokens(maxCompletionTokens: number): OpenAILegacyChatProvider { + const cap = Math.min(maxCompletionTokens, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); return this.withGenerationKwargs(completionTokenKwargs(this._model, Math.max(1, cap))); } diff --git a/packages/kosong/src/providers/pythinker.ts b/packages/kosong/src/providers/pythinker.ts index 47f22484..f0cf89ee 100644 --- a/packages/kosong/src/providers/pythinker.ts +++ b/packages/kosong/src/providers/pythinker.ts @@ -4,7 +4,6 @@ import type { ChatProvider, FinishReason, GenerateOptions, - MaxCompletionTokensOptions, ProviderRequestAuth, StreamedMessage, ThinkingEffort, @@ -552,19 +551,8 @@ export class PythinkerChatProvider implements ChatProvider { return this._withGenerationKwargs(kwargs); } - withMaxCompletionTokens( - maxCompletionTokens: number, - options?: MaxCompletionTokensOptions, - ): PythinkerChatProvider { - let cap = maxCompletionTokens; - if ( - options?.usedContextTokens !== undefined && - options?.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - return this._withGenerationKwargs({ max_completion_tokens: Math.max(1, cap) }); + withMaxCompletionTokens(maxCompletionTokens: number): PythinkerChatProvider { + 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 ed2e1e3a..350c689b 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -645,16 +645,12 @@ describe('OpenAILegacyChatProvider', () => { }); it('withMaxCompletionTokens clamps to the 128k ceiling', async () => { - const provider = createProvider().withMaxCompletionTokens(1000000, { - usedContextTokens: 30000, - maxContextTokens: 1000000, - }); + const provider = createProvider().withMaxCompletionTokens(1000000); const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, ]; const body = await captureRequestBody(provider, '', [], history); - // 1000000 - 30000 = 970000, clamped to 131072 expect(body['max_tokens']).toBe(131072); }); }); diff --git a/packages/kosong/test/pythinker.test.ts b/packages/kosong/test/pythinker.test.ts index f19b8310..332f0e6b 100644 --- a/packages/kosong/test/pythinker.test.ts +++ b/packages/kosong/test/pythinker.test.ts @@ -646,19 +646,6 @@ describe('PythinkerChatProvider', () => { expect(body['max_tokens']).toBeUndefined(); }); - it('withMaxCompletionTokens sizes the cap to the remaining context window', async () => { - const provider = createProvider().withMaxCompletionTokens(100000, { - usedContextTokens: 30000, - maxContextTokens: 100000, - }); - const history: Message[] = [ - { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, - ]; - const body = await captureRequestBody(provider, '', [], history); - - expect(body['max_completion_tokens']).toBe(70000); - }); - it('passes constructor generation kwargs into the request body', async () => { const provider = new PythinkerChatProvider({ model: 'pythinker-k2-turbo-preview', From 74f9c0976985cd30d63b0bc4a2a95e9bfc225e26 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 02:10:06 -0400 Subject: [PATCH 5/6] test: bound the compaction remaining-window cap against the history estimate --- .../agent-core/test/agent/compaction/full.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index b0f17859..072b97bf 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1908,9 +1908,17 @@ describe('FullCompaction', () => { expect(callCount).toBe(3); expect(compactionMaxCompletionTokens).toHaveLength(1); - const cap = compactionMaxCompletionTokens[0] as number; - expect(cap).toBeGreaterThanOrEqual(1); - expect(cap).toBeLessThan(maxContextTokens); + 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 () => { From 47254a00d5373c8a77536bd4801f4b2c6f487aa8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 04:53:58 -0400 Subject: [PATCH 6/6] fix: mention legacy env alias in doctor and refine /update in-progress notice --- apps/pythinker-code/src/cli/sub/doctor.ts | 5 ++++- apps/pythinker-code/src/tui/commands/info.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/pythinker-code/src/cli/sub/doctor.ts b/apps/pythinker-code/src/cli/sub/doctor.ts index e1358450..cc691e07 100644 --- a/apps/pythinker-code/src/cli/sub/doctor.ts +++ b/apps/pythinker-code/src/cli/sub/doctor.ts @@ -440,7 +440,10 @@ 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']; + 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)']; diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index 3ff3c4b0..d8f6e0e3 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -322,7 +322,9 @@ export async function handleUpdateCommand( host.showNotice( `Update to v${result.version} already in progress`, result.installOnRestart - ? 'Once preparation finishes, restart the CLI to install it.' + ? result.readyToInstall + ? 'Restart the CLI to install it.' + : 'Restart after the current update operation finishes.' : 'Restart the CLI once it completes.', ); return;