fix(pi): keep active runs alive during background sleep

This commit is contained in:
2026-08-25 20:56:17 +08:00
parent 92f4c91088
commit 621ebb1781
22 changed files with 1061 additions and 68 deletions

View File

@@ -901,6 +901,7 @@ describe('auth host api routes', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
expect(stop).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledWith('auth_cleanup');
expect(closeEventSessions).toHaveBeenCalledOnce();
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
});
@@ -927,6 +928,7 @@ describe('auth host api routes', () => {
success: false,
error: 'Failed to clear local AI runtime state',
});
expect(stop).toHaveBeenCalledWith('auth_cleanup');
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
});

View File

@@ -50,6 +50,36 @@ describe('BackgroundLifecycleController', () => {
controller.dispose();
});
it('does not stop the runtime when a lease is acquired while sleep preparation is in flight', async () => {
vi.useFakeTimers();
let finishSleepPreparation!: () => void;
const sleepPreparation = new Promise<void>((resolve) => {
finishSleepPreparation = resolve;
});
const onSleep = vi.fn(() => sleepPreparation);
const onStopRuntime = vi.fn().mockResolvedValue(undefined);
const controller = new BackgroundLifecycleController({
idleStopMs: 60_000,
onSleep,
onStopRuntime,
});
controller.setActivity({ visible: false, module: 'learning' });
await vi.advanceTimersByTimeAsync(60_000);
expect(onSleep).toHaveBeenCalledTimes(1);
const release = controller.acquireLease({ id: 'coding-run:1', kind: 'coding-run' });
finishSleepPreparation();
await vi.runAllTicks();
expect(onStopRuntime).not.toHaveBeenCalled();
release();
await vi.advanceTimersByTimeAsync(60_000);
expect(onStopRuntime).toHaveBeenCalledTimes(1);
controller.dispose();
});
it('cancels the idle stop when programming becomes visible again', async () => {
vi.useFakeTimers();
const onSleep = vi.fn();

View File

@@ -270,9 +270,11 @@ describe('PI-100 coding core Host contract', () => {
await archivePiConversationSession({ userDataDir, ...input });
},
});
const dispose = vi.spyOn(result.runtime, 'dispose');
await conversations.deleteConversation(conversation.id);
expect(dispose).toHaveBeenCalledWith(conversation.id, 'conversation_deleted');
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull();
await expect(readFile(path.join(
userDataDir,
@@ -652,16 +654,24 @@ describe('PI-100 coding core Host contract', () => {
createId: () => `project-${++nextId}`,
});
const first = await createLocalCodingProject({ projectPath: projectRoots[0]! }, store);
const deactivated: string[] = [];
const deactivated: Array<{ projectId: string; reason: string }> = [];
const projects = new CodingProjectService(store, {
onProjectDeactivated: async (project) => { deactivated.push(project.id); },
onProjectDeactivated: async (project, reason) => {
deactivated.push({ projectId: project.id, reason });
},
});
const opened = await projects.openProject(projectRoots[1]!);
const created = await projects.createProject({ projectPath: projectRoots[2]! });
await projects.setActiveProject(first.project.id);
await projects.removeProject(first.project.id);
expect(deactivated).toEqual([first.project.id, opened.id, created.project.id]);
expect(deactivated).toEqual([
{ projectId: first.project.id, reason: 'project_deactivated' },
{ projectId: opened.id, reason: 'project_deactivated' },
{ projectId: created.project.id, reason: 'project_deactivated' },
{ projectId: first.project.id, reason: 'project_removed' },
]);
});
it('removes absolute project roots from every core project response', async () => {
@@ -852,7 +862,7 @@ describe('PI-100 coding core Host contract', () => {
await expect(conversations.fork(source.id, 'entry-user-fork-cleanup')).rejects.toMatchObject({
code: 'CODING_SESSION_UNREADABLE',
});
expect(dispose).toHaveBeenCalledWith(forkTargetId);
expect(dispose).toHaveBeenCalledWith(forkTargetId, 'fork_replacement');
expect(archiveSession).toHaveBeenCalledWith({
projectId: 'project-a',
sessionKey: 'fork-session-key',

View File

@@ -0,0 +1,261 @@
// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime';
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
import {
PiWorkerPool,
type PiConversationWorker,
} from '../../electron/coding-runtime/pi/worker-pool';
import type {
PiRpcCommand,
PiRpcEvent,
PiRpcRequestOptions,
PiRpcResponse,
} from '../../electron/coding-runtime/pi/rpc-client';
import type { PiWorkerStopReason } from '../../electron/coding-runtime/pi/worker-process';
import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
import { BackgroundLifecycleController } from '../../electron/main/background-lifecycle';
const roots: string[] = [];
const NOW = '2026-08-25T12:00:00.000Z';
class LifecycleFakeWorker implements PiConversationWorker {
readonly requests: PiRpcCommand[] = [];
readonly stopReasons: PiWorkerStopReason[] = [];
private readonly events = new Set<(event: PiRpcEvent) => void>();
private readonly invalidations = new Set<(error: PiProcessError) => void>();
constructor(readonly id: string, readonly generation: number) {}
async request<T = unknown>(
command: PiRpcCommand,
_options?: PiRpcRequestOptions,
): Promise<PiRpcResponse<T>> {
this.requests.push(structuredClone(command));
const data = command.type === 'get_state'
? {
sessionId: `session-${this.id}`,
thinkingLevel: 'medium',
isStreaming: false,
isCompacting: false,
pendingMessageCount: 0,
}
: command.type === 'get_entries'
? { entries: [], leafId: null }
: command.type === 'get_session_stats'
? {
contextUsage: { tokens: 0, contextWindow: 100_000, percent: 0 },
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}
: command.type === 'get_commands'
? { commands: [] }
: command.type === 'get_available_thinking_levels'
? { levels: ['off', 'medium', 'high'] }
: undefined;
return {
type: 'response',
id: `${this.id}-${this.requests.length}`,
success: true,
...(data === undefined ? {} : { data: structuredClone(data) as T }),
};
}
async send(command: PiRpcCommand): Promise<void> {
this.requests.push(structuredClone(command));
}
subscribe(listener: (event: PiRpcEvent) => void): () => void {
this.events.add(listener);
return () => this.events.delete(listener);
}
subscribeInvalidation(listener: (error: PiProcessError) => void): () => void {
this.invalidations.add(listener);
return () => this.invalidations.delete(listener);
}
emit(event: PiRpcEvent): void {
for (const listener of this.events) listener(event);
}
async stop(reason: PiWorkerStopReason) {
this.stopReasons.push(reason);
return { mode: 'stdin-close' as const, code: 0, signal: null };
}
}
afterEach(async () => {
vi.useRealTimers();
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('Pi run background lifecycle lease', () => {
it('keeps accepted running and queued work alive while hidden, then evicts idle workers', async () => {
vi.useFakeTimers();
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-background-'));
roots.push(projectPath);
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-a',
now: () => NOW,
});
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
await createCodingProjectAgent(projectPath, {
id: 'agent-a',
avatarId: 'avatar-01',
roleName: 'Implementer',
name: 'Agent A',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
responsibility: {
mission: 'Implement',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
}, { now: NOW });
const store = createCodingConversationStore(projectPath, {
createId: (() => {
const ids = [
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'8b1a9953-c461-4d88-9c3e-7e1f8f3f2c11',
];
return () => ids.shift() as string;
})(),
now: () => NOW,
});
const runningConversation = await store.create({
agentId: 'agent-a',
title: 'Running lease proof',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
});
const queuedConversation = await store.create({
agentId: 'agent-a',
title: 'Queued lease proof',
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
});
const inputs = [runningConversation, queuedConversation].map((conversation) => ({
conversationId: conversation.id,
projectId: 'project-a',
agentId: 'agent-a',
title: conversation.title,
model: { model: conversation.model, modelResolution: conversation.modelResolution },
}));
const workers: LifecycleFakeWorker[] = [];
const workersByConversation = new Map<string, LifecycleFakeWorker>();
const pool = new PiWorkerPool({
maxRunning: 1,
openWorker: async ({ conversation: workerConversation, generation, existingSession }) => {
const worker = new LifecycleFakeWorker(
`worker-${workerConversation.conversationId}-${generation}`,
generation,
);
workers.push(worker);
workersByConversation.set(workerConversation.conversationId, worker);
return {
worker,
session: existingSession ?? {
piSessionId: `session-${workerConversation.conversationId}`,
sessionKey: `key-${workerConversation.conversationId}`,
},
};
},
});
let runtime!: PiConversationRuntime;
const onSleep = vi.fn().mockResolvedValue(undefined);
const onStopRuntime = vi.fn(async () => {
await Promise.all(runtime.getDiagnostics().workers.map(async ({ conversationId }) => {
await runtime.dispose(conversationId, 'background_sleep');
}));
});
const controller = new BackgroundLifecycleController({
idleStopMs: 100,
onSleep,
onStopRuntime,
});
const acquiredLeaseIds: string[] = [];
const releasedLeaseIds: string[] = [];
runtime = new PiConversationRuntime({
pool,
registry: new PiSessionRegistry({ projectStore }),
createId: (kind) => `${kind}-lease-proof`,
acquireBackgroundLease: (lease) => {
acquiredLeaseIds.push(lease.id);
const release = controller.acquireLease(lease);
return () => {
releasedLeaseIds.push(lease.id);
release();
};
},
});
await Promise.all(inputs.map(async (input) => await runtime.prepare(input)));
const accepted = await runtime.prompt({
clientRequestId: 'request-1',
conversationId: runningConversation.id,
mode: 'prompt',
text: 'Keep working while hidden',
attachments: [],
});
const queued = await runtime.prompt({
clientRequestId: 'request-2',
conversationId: queuedConversation.id,
mode: 'prompt',
text: 'Wait safely while hidden',
attachments: [],
});
expect(accepted.accepted).toBe(true);
expect(queued).toMatchObject({ accepted: true, queuePosition: 1 });
expect(controller.getLeaseCount()).toBe(2);
const releaseChild = pool.trackGenerationResource({
conversationId: runningConversation.id,
kind: 'child',
id: 'child-proof',
cancel: () => undefined,
});
releaseChild();
expect(controller.getLeaseCount()).toBe(2);
controller.setActivity({ visible: false, module: 'programming' });
await vi.advanceTimersByTimeAsync(100);
expect(onStopRuntime).not.toHaveBeenCalled();
expect(workers.every(({ stopReasons }) => stopReasons.length === 0)).toBe(true);
expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('running');
expect((await runtime.getSnapshot(queuedConversation.id)).run.status).toBe('queued');
workersByConversation.get(runningConversation.id)!.emit({ type: 'agent_settled' });
expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('idle');
await expect.poll(async () => (
await runtime.getSnapshot(queuedConversation.id)
).run.status).toBe('running');
expect(controller.getLeaseCount()).toBe(1);
workersByConversation.get(queuedConversation.id)!.emit({ type: 'agent_settled' });
expect((await runtime.getSnapshot(queuedConversation.id)).run.status).toBe('idle');
expect(controller.getLeaseCount()).toBe(0);
expect(releasedLeaseIds.sort()).toEqual(acquiredLeaseIds.sort());
expect(new Set(releasedLeaseIds).size).toBe(releasedLeaseIds.length);
await vi.advanceTimersByTimeAsync(100);
expect(onSleep).toHaveBeenCalledTimes(1);
expect(onStopRuntime).toHaveBeenCalledTimes(1);
expect(workers.map(({ stopReasons }) => stopReasons)).toEqual([
['background_sleep'],
['background_sleep'],
]);
controller.dispose();
});
});

View File

@@ -19,6 +19,7 @@ import {
PiWorkerPool,
type PiConversationWorker,
} from '../../electron/coding-runtime/pi/worker-pool';
import type { PiWorkerStopReason } from '../../electron/coding-runtime/pi/worker-process';
import { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
import type {
PiRpcCommand,
@@ -33,6 +34,7 @@ const NOW = '2026-08-22T15:00:00.000Z';
class RuntimeFakeWorker implements PiConversationWorker {
readonly requests: PiRpcCommand[] = [];
readonly stopReasons: PiWorkerStopReason[] = [];
private stateData: unknown;
private entriesData: unknown = { entries: [], leafId: null };
private statsData: unknown = {
@@ -143,7 +145,8 @@ class RuntimeFakeWorker implements PiConversationWorker {
for (const listener of this.invalidations) listener(error);
}
async stop() {
async stop(reason: PiWorkerStopReason) {
this.stopReasons.push(reason);
return { mode: 'stdin-close' as const, code: 0, signal: null };
}
}
@@ -236,11 +239,25 @@ describe('Pi Conversation runtime', () => {
},
});
const trackingHost = new TrackingExtensionHost();
let acquiredBackgroundLeases = 0;
let releasedBackgroundLeases = 0;
let activeBackgroundLeases = 0;
const runtime = new PiConversationRuntime({
pool,
registry: new PiSessionRegistry({ projectStore }),
createId: (kind) => `${kind}-fixed`,
extensionHost: trackingHost,
acquireBackgroundLease: () => {
acquiredBackgroundLeases += 1;
activeBackgroundLeases += 1;
let released = false;
return () => {
if (released) throw new Error('background lease released twice');
released = true;
releasedBackgroundLeases += 1;
activeBackgroundLeases -= 1;
};
},
resolveModel: async (candidate) => {
if (candidate.accountId !== 'account-b'
|| !['model-b', 'model-c'].includes(candidate.modelId)) {
@@ -297,6 +314,7 @@ describe('Pi Conversation runtime', () => {
releasePromptAcceptance();
const accepted = await acceptance;
expect(accepted).toMatchObject({ accepted: true, runId: 'run-fixed', mode: 'prompt' });
expect(activeBackgroundLeases).toBe(1);
expect((await runtime.getSnapshot(left.id)).run.status).toBe('running');
for (const event of PI_084_TEXT_TURN.events) {
workers.get(left.id)!.emit(structuredClone(event));
@@ -372,6 +390,7 @@ describe('Pi Conversation runtime', () => {
workers.get(left.id)!.emit({ type: 'agent_settled' });
await expect.poll(async () => (await runtime.getSnapshot(left.id)).queue.items).toEqual([]);
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
expect(activeBackgroundLeases).toBe(0);
const settledNodes = (await runtime.getSnapshot(left.id)).nodes;
expect(settledNodes).toEqual(checkpoint.nodes);
expect(leftGenerationOneSeqs).toEqual(leftGenerationOneSeqs.map((_, index) => index + 1));
@@ -483,13 +502,16 @@ describe('Pi Conversation runtime', () => {
]);
workers.get(left.id)!.emit({ type: 'agent_settled' });
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('idle');
expect(activeBackgroundLeases).toBe(0);
expect((await runtime.getSnapshot(left.id)).queue.items).toEqual([]);
workers.get(right.id)!.failNext('compact');
await expect(runtime.compact(right.id)).rejects.toThrow('fake compact rejection');
expect((await runtime.getSnapshot(right.id)).run.status).toBe('error');
expect(activeBackgroundLeases).toBe(0);
await runtime.compact(right.id);
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
expect(activeBackgroundLeases).toBe(1);
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
const rightDurable = {
@@ -510,6 +532,7 @@ describe('Pi Conversation runtime', () => {
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
workers.get(right.id)!.emit({ type: 'agent_settled' });
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
expect(activeBackgroundLeases).toBe(0);
const leftBeforeFailedRecovery = await runtime.getSnapshot(left.id);
const rightBeforeFailedRecovery = await runtime.getSnapshot(right.id);
@@ -568,7 +591,7 @@ describe('Pi Conversation runtime', () => {
sourceEntryId: 'entry-a',
});
await runtime.dispose(forkTarget.id);
await runtime.dispose(forkTarget.id, 'test_injection');
expect(pool.getState(forkTarget.id)).toBeNull();
expect(pool.getState(left.id)).toMatchObject({ generation: 2, state: 'idle' });
await expect(runtime.getSnapshot(forkTarget.id)).rejects.toMatchObject({
@@ -585,6 +608,7 @@ describe('Pi Conversation runtime', () => {
await runtime.recover(left.id);
expect(trackingHost.runs.has(left.id)).toBe(false);
expect((await runtime.getSnapshot(left.id)).run).toEqual({ status: 'idle' });
expect(activeBackgroundLeases).toBe(0);
workers.get(left.id)!.setSessionData({
thinkingLevels: { levels: ['high'] },
@@ -620,6 +644,7 @@ describe('Pi Conversation runtime', () => {
crashPatchOps.length = 0;
workers.get(left.id)!.invalidate();
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('error');
expect(activeBackgroundLeases).toBe(0);
expect((await runtime.getSnapshot(left.id))).toMatchObject({
run: {
status: 'error',
@@ -669,6 +694,7 @@ describe('Pi Conversation runtime', () => {
'strict JSONL protocol failure',
));
await expect.poll(async () => (await runtime.getSnapshot(left.id)).run.status).toBe('error');
expect(activeBackgroundLeases).toBe(0);
expect((await runtime.getSnapshot(left.id)).run).toMatchObject({
terminalReason: 'failed',
error: { code: 'CODING_RUNTIME_PROTOCOL_ERROR', recoverable: true },
@@ -676,6 +702,47 @@ describe('Pi Conversation runtime', () => {
workers.get(left.id)!.emit({ type: 'agent_settled' });
await new Promise((resolve) => setImmediate(resolve));
expect((await runtime.getSnapshot(left.id)).run.status).toBe('error');
await runtime.recover(left.id);
const siblingBeforeForcedDispose = await runtime.getSnapshot(right.id);
const bindingBeforeForcedDispose = await store.get(left.id);
const forcedDisposePatches: string[] = [];
const unsubscribeForcedDispose = runtime.subscribe((envelope) => {
if (envelope.conversationId === left.id && envelope.patch.op === 'run.state') {
forcedDisposePatches.push(JSON.stringify(envelope.patch));
}
});
const forcedWorker = workers.get(left.id)!;
await runtime.prompt({
clientRequestId: 'request-project-deactivated',
conversationId: left.id,
mode: 'prompt',
text: 'Do not replay this accepted prompt',
attachments: [],
});
const forcedPromptCount = forcedWorker.requests.filter(({ type }) => type === 'prompt').length;
await runtime.dispose(left.id, 'project_deactivated');
unsubscribeForcedDispose();
expect(forcedDisposePatches.at(-1)).toContain('CODING_RUNTIME_START_FAILED');
expect(forcedDisposePatches.at(-1)).toContain('"recoverable":true');
expect(forcedWorker.stopReasons.at(-1)).toBe('project_deactivated');
expect(activeBackgroundLeases).toBe(0);
expect(await runtime.getSnapshot(right.id)).toEqual(siblingBeforeForcedDispose);
expect((await store.get(left.id))?.sessionKey).toBe(bindingBeforeForcedDispose?.sessionKey);
const persistedAfterForcedDispose = (await store.get(left.id))!;
await runtime.prepare({
...inputs[0]!,
model: {
model: persistedAfterForcedDispose.model,
modelResolution: persistedAfterForcedDispose.modelResolution,
},
});
expect(forcedWorker.requests.filter(({ type }) => type === 'prompt')).toHaveLength(
forcedPromptCount,
);
expect(acquiredBackgroundLeases).toBe(releasedBackgroundLeases);
expect(activeBackgroundLeases).toBe(0);
unsubscribe();
});
});

View File

@@ -25,4 +25,19 @@ describe('Pi packaged release proof wiring', () => {
expect(source.slice(proxyStart, resilienceStart))
.not.toContain("startLocalProofProvider('subagent')");
});
it('wires the packaged resilience proof through Main background and intentional-dispose seams', async () => {
const [mainSource, proofSource, scriptSource] = await Promise.all([
readFile(resolve('electron/main/index.ts'), 'utf8'),
readFile(resolve('electron/coding-runtime/pi/release-proof.ts'), 'utf8'),
readFile(resolve('scripts/run-pi-subagent-packaged-smoke.mjs'), 'utf8'),
]);
expect(mainSource).toContain("codingProducts?.sleep('background_sleep')");
expect(mainSource).toContain('acquireBackgroundLease: (lease) => backgroundLifecycle!');
expect(proofSource).toContain("runtime.dispose(run.targetConversationId, 'test_injection')");
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.idle-status')");
expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)');
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')");
});
});

View File

@@ -261,7 +261,7 @@ describe('Pi worker pool', () => {
await expect.poll(() => processBudget.waitingCount).toBe(1);
expect(opened).toEqual(['conversation-a', 'conversation-b']);
await pool.dispose('conversation-a');
await pool.dispose('conversation-a', 'test_injection');
await expect(third).resolves.toMatchObject({ conversationId: 'conversation-c', state: 'ready' });
expect(opened).toEqual(['conversation-a', 'conversation-b', 'conversation-c']);
expect(processBudget.activeCount).toBe(2);
@@ -292,7 +292,7 @@ describe('Pi worker pool', () => {
});
await pool.prepare(conversation('conversation-stop-failure'));
await expect(pool.dispose('conversation-stop-failure')).rejects.toThrow('stop failed');
await expect(pool.dispose('conversation-stop-failure', 'test_injection')).rejects.toThrow('stop failed');
expect(processBudget.activeCount).toBe(0);
expect(processBudget.waitingCount).toBe(0);
});
@@ -804,7 +804,7 @@ describe('Pi worker pool', () => {
expect(workers.get('conversation-a')![0]!.stopped).toBe(true);
expect(workers.get('conversation-b')![0]!.stopped).toBe(false);
await pool.dispose('conversation-a');
await pool.dispose('conversation-a', 'test_injection');
expect(workers.get('conversation-a')![1]!.stopped).toBe(true);
expect(pool.getState('conversation-a')).toBeNull();
expect(pool.getState('conversation-b')).toMatchObject({ state: 'ready', generation: 1 });