pi-simple-background-tasks 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORK-CHANGES.md +10 -3
- package/README.md +19 -9
- package/package.json +3 -2
- package/src/core/common.ts +26 -207
- package/src/core/detached-spawn.ts +317 -0
- package/src/core/registry.ts +586 -1203
- package/src/core/task-cleanup.ts +6 -0
- package/src/core/windows-taskkill.ts +4 -11
- package/src/core/windows-wmi-spawn.ts +192 -0
- package/src/extension.ts +34 -180
- package/src/core/reload-shell-owner.ts +0 -1512
package/src/core/task-cleanup.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { TERMINAL_TASK_STATUS_VALUES } from './common.js';
|
|
4
|
+
import { EXIT_MARKER_SUFFIX, RUNNER_SCRIPT_SUFFIX } from './detached-spawn.js';
|
|
4
5
|
|
|
5
6
|
export type CleanupScope = 'one' | 'session' | 'all';
|
|
6
7
|
|
|
@@ -59,6 +60,11 @@ async function readTaskStatus(jsonPath: string): Promise<string | undefined> {
|
|
|
59
60
|
async function deleteTaskFiles(runDir: string, id: string): Promise<void> {
|
|
60
61
|
await rm(join(runDir, `${id}.json`), { force: true });
|
|
61
62
|
await rm(join(runDir, `${id}.output`), { force: true });
|
|
63
|
+
// Ownership artifacts of a settled task: the wrapper marker and the
|
|
64
|
+
// generated runner script. Both are dead weight once the record is
|
|
65
|
+
// terminal, and they keep the run dir from ever becoming prunable.
|
|
66
|
+
await rm(join(runDir, `${id}${EXIT_MARKER_SUFFIX}`), { force: true });
|
|
67
|
+
await rm(join(runDir, `${id}${RUNNER_SCRIPT_SUFFIX}`), { force: true });
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
async function removeEmptyRunDir(runDir: string): Promise<void> {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
2
|
import { win32 } from 'node:path';
|
|
3
3
|
|
|
4
|
-
export type WindowsKillPhase = 'terminate' | 'force';
|
|
5
|
-
|
|
6
4
|
export interface TaskkillOutcome {
|
|
7
5
|
readonly exitCode: number | null;
|
|
8
6
|
readonly signal: NodeJS.Signals | null;
|
|
@@ -117,11 +115,7 @@ function validatePid(pid: number): void {
|
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
|
|
121
|
-
if (phase !== 'terminate' && phase !== 'force') {
|
|
122
|
-
throw new Error(`Invalid Windows taskkill phase ${String(phase)}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
118
|
+
// (the two-phase 'terminate'/'force' split was removed: /F is the only kill)
|
|
125
119
|
|
|
126
120
|
function positiveFiniteInteger(value: number | undefined, fallback: number, label: string): number {
|
|
127
121
|
const candidate = value ?? fallback;
|
|
@@ -157,11 +151,9 @@ function defaultSpawn(
|
|
|
157
151
|
|
|
158
152
|
export function runWindowsTaskkill(
|
|
159
153
|
pid: number,
|
|
160
|
-
phase: WindowsKillPhase,
|
|
161
154
|
options: WindowsTaskkillOptions = {},
|
|
162
155
|
): Promise<TaskkillOutcome> {
|
|
163
156
|
validatePid(pid);
|
|
164
|
-
validatePhase(phase);
|
|
165
157
|
const env = options.env ?? process.env;
|
|
166
158
|
const taskkill = resolveTaskkillPath(env);
|
|
167
159
|
const timeoutMs = positiveFiniteInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
|
|
@@ -173,8 +165,9 @@ export function runWindowsTaskkill(
|
|
|
173
165
|
const spawn = options.spawn ?? defaultSpawn;
|
|
174
166
|
const abortSignal = options.signal;
|
|
175
167
|
|
|
176
|
-
|
|
177
|
-
|
|
168
|
+
// Single tree termination: console applications do not honor window-close
|
|
169
|
+
// termination, so forced termination is the only kill that works.
|
|
170
|
+
const args = ['/PID', String(pid), '/T', '/F'];
|
|
178
171
|
|
|
179
172
|
if (abortSignal?.aborted) {
|
|
180
173
|
const stdout = new BoundedCapture(maxCaptureBytes);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Windows detach helper: the task's outer process is created by the WMI
|
|
6
|
+
* service (`Win32_Process.Create`), not by the extension process.
|
|
7
|
+
*
|
|
8
|
+
* Background-task survival on this platform requires the task to be outside
|
|
9
|
+
* every process job and console session that belongs to the Pi activation:
|
|
10
|
+
* children spawned directly by the Pi process die together with it (job and
|
|
11
|
+
* console-session teardown), no matter how they are detached. Processes
|
|
12
|
+
* created through WMI have `WmiPrvSE.exe` as their service-side parent and
|
|
13
|
+
* outlive the activating process, its console, and its SSH/terminal session
|
|
14
|
+
* entirely — verified end to end on Windows 11.
|
|
15
|
+
*
|
|
16
|
+
* The helper is a short-lived PowerShell one-shot: it performs exactly one
|
|
17
|
+
* `Win32_Process.Create` and prints the new pid. It is spawned directly by the
|
|
18
|
+
* extension and never survives the launch itself, so a dead activation can
|
|
19
|
+
* never leave a launch in flight (failures surface as launch errors).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface WmiHelperChild {
|
|
23
|
+
readonly pid?: number | undefined;
|
|
24
|
+
readonly stdout: {
|
|
25
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
26
|
+
};
|
|
27
|
+
readonly stderr: {
|
|
28
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
29
|
+
};
|
|
30
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
31
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
32
|
+
on(
|
|
33
|
+
event: 'close',
|
|
34
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
35
|
+
): unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type WmiSpawnFn = (
|
|
39
|
+
file: string,
|
|
40
|
+
args: string[],
|
|
41
|
+
options: { env: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean },
|
|
42
|
+
) => WmiHelperChild;
|
|
43
|
+
|
|
44
|
+
export interface WindowsWmiSpawnOptions {
|
|
45
|
+
/** Full CreateProcess command line for the new process. */
|
|
46
|
+
readonly commandLine: string;
|
|
47
|
+
readonly currentDirectory: string;
|
|
48
|
+
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
49
|
+
/** Test seam; defaults to PowerShell via SystemRoot. */
|
|
50
|
+
readonly spawn?: WmiSpawnFn | undefined;
|
|
51
|
+
readonly timeoutMs?: number | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const WMI_CREATE_TIMEOUT_MS = 20_000;
|
|
55
|
+
|
|
56
|
+
const PS_HEADER = ['$ErrorActionPreference = "Stop"'].join('\n');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the one-shot PowerShell script: creates the process hidden, reports
|
|
60
|
+
* failures as `WMIERR <ReturnValue>` on stdout, otherwise prints the pid.
|
|
61
|
+
*/
|
|
62
|
+
export function buildWindowsWmiCreateScript(commandLine: string, currentDirectory: string): string {
|
|
63
|
+
return [
|
|
64
|
+
PS_HEADER,
|
|
65
|
+
`$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{` +
|
|
66
|
+
`CommandLine = '${escapePowerShellSingleQuoted(commandLine)}';` +
|
|
67
|
+
`CurrentDirectory = '${escapePowerShellSingleQuoted(currentDirectory)}' }`,
|
|
68
|
+
'if ($r.ReturnValue -ne 0) { Write-Output ("WMIERR " + $r.ReturnValue); exit 1 }',
|
|
69
|
+
'Write-Output $r.ProcessId',
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapePowerShellSingleQuoted(value: string): string {
|
|
74
|
+
return value.replace(/'/g, "''");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function encodeWindowsWmiScript(script: string): string {
|
|
78
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function powershellExecutable(env: NodeJS.ProcessEnv = process.env): string {
|
|
82
|
+
const windir = env.Windir ?? env.SYSTEMROOT ?? env.winDir ?? 'C:\\Windows';
|
|
83
|
+
return join(windir, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parses `WMIERR N` / `<pid>` helper output into either a failure or a pid. */
|
|
87
|
+
export function parseWmiHelperOutput(stdout: string, stderr: string): { pid: number } {
|
|
88
|
+
const lines = stdout
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.map((line) => line.trim())
|
|
91
|
+
.filter((line) => line.length > 0);
|
|
92
|
+
const errLine = lines.find((line) => line.startsWith('WMIERR'));
|
|
93
|
+
if (errLine !== undefined) {
|
|
94
|
+
const code = errLine.slice('WMIERR'.length).trim();
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Win32_Process.Create failed (ReturnValue ${code || 'unknown'})` +
|
|
97
|
+
(stderr.length > 0 ? `; helper output: ${stderr.trim()}` : ''),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const candidate = lines[lines.length - 1];
|
|
101
|
+
const pid = candidate === undefined ? Number.NaN : Number(candidate);
|
|
102
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Windows WMI spawn did not return a pid${stderr.length > 0 ? `; helper output: ${stderr.trim()}` : ''}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return { pid };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function createProcessViaWmi(
|
|
111
|
+
options: WindowsWmiSpawnOptions,
|
|
112
|
+
): Promise<{ pid: number }> {
|
|
113
|
+
const spawn = options.spawn ?? defaultWmiSpawn;
|
|
114
|
+
const env = options.env ?? process.env;
|
|
115
|
+
const scriptFile = encodeWindowsWmiScript(
|
|
116
|
+
buildWindowsWmiCreateScript(options.commandLine, options.currentDirectory),
|
|
117
|
+
);
|
|
118
|
+
const timeoutMs = options.timeoutMs ?? WMI_CREATE_TIMEOUT_MS;
|
|
119
|
+
|
|
120
|
+
const child = spawn(
|
|
121
|
+
powershellExecutable(env),
|
|
122
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', scriptFile],
|
|
123
|
+
{
|
|
124
|
+
env,
|
|
125
|
+
windowsVerbatimArguments: false,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const stdout: string[] = [];
|
|
130
|
+
const stderr: string[] = [];
|
|
131
|
+
child.stdout?.on('data', (chunk) => stdout.push(String(chunk)));
|
|
132
|
+
child.stderr?.on('data', (chunk) => stderr.push(String(chunk)));
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const rejectOnce = (error: Error): void => {
|
|
136
|
+
if (settled) return;
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(error);
|
|
140
|
+
};
|
|
141
|
+
let settled = false;
|
|
142
|
+
let timer: NodeJS.Timeout | undefined = setTimeout(() => {
|
|
143
|
+
timer = undefined;
|
|
144
|
+
rejectOnce(
|
|
145
|
+
new Error(
|
|
146
|
+
`Windows WMI spawn exceeded ${String(Math.round(timeoutMs / 1000))}s timeout; the helper was terminated`,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
try {
|
|
150
|
+
child.kill('SIGKILL');
|
|
151
|
+
} catch {
|
|
152
|
+
// Rejection is authoritative; the helper may already have exited.
|
|
153
|
+
}
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
|
|
156
|
+
const close = (code: number | null): void => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
160
|
+
const detail =
|
|
161
|
+
(code === null ? 'exit signal' : `exit ${String(code)}`) + tailOutput(stderr.join(''));
|
|
162
|
+
if (code !== 0) {
|
|
163
|
+
reject(new Error(`Windows WMI helper failed (${detail})`));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
resolve(parseWmiHelperOutput(stdout.join(''), stderr.join('')));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
child.on('error', (error: Error) =>
|
|
173
|
+
rejectOnce(new Error(`Windows WMI helper failed to start: ${error.message}`)),
|
|
174
|
+
);
|
|
175
|
+
child.on('close', (code: number | null) => close(code));
|
|
176
|
+
});
|
|
177
|
+
function tailOutput(stderrText: string): string {
|
|
178
|
+
const normalized = stderrText.trim();
|
|
179
|
+
return normalized.length === 0 ? '' : `; helper output: ${normalized}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function defaultWmiSpawn(
|
|
184
|
+
file: string,
|
|
185
|
+
args: string[],
|
|
186
|
+
options: { env: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean },
|
|
187
|
+
): WmiHelperChild {
|
|
188
|
+
return nodeSpawn(file, args, {
|
|
189
|
+
env: options.env,
|
|
190
|
+
windowsVerbatimArguments: false,
|
|
191
|
+
}) as unknown as WmiHelperChild;
|
|
192
|
+
}
|
package/src/extension.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { realpathSync } from 'node:fs';
|
|
3
1
|
import type {
|
|
4
2
|
ExtensionAPI,
|
|
5
3
|
ExtensionCommandContext,
|
|
@@ -32,10 +30,6 @@ import {
|
|
|
32
30
|
normalizeMaxBytes,
|
|
33
31
|
normalizeTaskName,
|
|
34
32
|
parseBgCommandArgs,
|
|
35
|
-
type ReloadShellActivationClaimV1,
|
|
36
|
-
type ReloadShellActivationLeaseV1,
|
|
37
|
-
type ReloadShellIdentityV1,
|
|
38
|
-
ReloadSurvivalError,
|
|
39
33
|
type StartTaskOptions,
|
|
40
34
|
taskDisplayName,
|
|
41
35
|
truncateChars,
|
|
@@ -47,10 +41,6 @@ import {
|
|
|
47
41
|
} from './core/extension-api.js';
|
|
48
42
|
import { LazyModule, SynchronousActivationCloseFence } from './core/lazy-module.js';
|
|
49
43
|
import { type BackgroundTaskContext, BackgroundTaskRegistry } from './core/registry.js';
|
|
50
|
-
import {
|
|
51
|
-
getProcessReloadShellOwnerV1,
|
|
52
|
-
makeReloadShellIdentity,
|
|
53
|
-
} from './core/reload-shell-owner.js';
|
|
54
44
|
import { createShellPolicyGuidanceHandler, initializeShellPolicy } from './core/shell-policy.js';
|
|
55
45
|
import type { CleanupScope, CleanupSummary } from './core/task-cleanup.js';
|
|
56
46
|
import {
|
|
@@ -66,9 +56,11 @@ import {
|
|
|
66
56
|
* Scope:
|
|
67
57
|
* - Explicit background shell jobs only: /bg and bg_run spawn commands directly.
|
|
68
58
|
* - No Ctrl+B support for backgrounding an already-running built-in bash tool.
|
|
69
|
-
* -
|
|
70
|
-
*
|
|
71
|
-
* -
|
|
59
|
+
* - Tasks always survive /reload and Pi restarts: every task spawns detached,
|
|
60
|
+
* its ownership facts live in its on-disk metadata, and each activation
|
|
61
|
+
* adopts still-running records from disk. Pi session shutdown kills all
|
|
62
|
+
* running tasks, so exit with care.
|
|
63
|
+
* - No flag negotiates any of this: there is exactly one launch path.
|
|
72
64
|
*/
|
|
73
65
|
|
|
74
66
|
const COMMAND_PREVIEW_CHARS = 90;
|
|
@@ -88,6 +80,7 @@ interface BgToolArgumentRecord {
|
|
|
88
80
|
readonly timeoutSeconds?: unknown;
|
|
89
81
|
readonly notifyOnCompletion?: unknown;
|
|
90
82
|
readonly triggerOnCompletion?: unknown;
|
|
83
|
+
/** Legacy argument, silently ignored: survival is now unconditional. */
|
|
91
84
|
readonly surviveReload?: unknown;
|
|
92
85
|
}
|
|
93
86
|
|
|
@@ -119,12 +112,6 @@ const BgRunParams = Type.Object({
|
|
|
119
112
|
'Whether that notification should automatically trigger a follow-up agent turn. Default: true for bg_run; requires notifyOnCompletion.',
|
|
120
113
|
}),
|
|
121
114
|
),
|
|
122
|
-
surviveReload: Type.Optional(
|
|
123
|
-
Type.Boolean({
|
|
124
|
-
description:
|
|
125
|
-
'Opt in to retaining this exact shell execution across a real same-process Pi reload. Default: false.',
|
|
126
|
-
}),
|
|
127
|
-
),
|
|
128
115
|
});
|
|
129
116
|
|
|
130
117
|
const BgStatusParams = Type.Object({
|
|
@@ -213,15 +200,8 @@ function formatCleanMessage(summary: CleanupSummary): string {
|
|
|
213
200
|
export default async function backgroundTasksExtension(pi: ExtensionAPI): Promise<void> {
|
|
214
201
|
const config = resolveBackgroundTasksConfig(process.cwd());
|
|
215
202
|
const shellPolicy = initializeShellPolicy();
|
|
216
|
-
const reloadShellOwner = getProcessReloadShellOwnerV1();
|
|
217
203
|
pi.on('before_agent_start', createShellPolicyGuidanceHandler(shellPolicy));
|
|
218
|
-
let currentRegistryCtx: BackgroundTaskContext | undefined;
|
|
219
|
-
let activationLease: ReloadShellActivationLeaseV1 | undefined;
|
|
220
|
-
let activationIdentity: ReloadShellIdentityV1 | undefined;
|
|
221
|
-
let pendingActivationClaim: ReloadShellActivationClaimV1 | undefined;
|
|
222
204
|
let disposed = false;
|
|
223
|
-
let shutdownCleanupStarted = false;
|
|
224
|
-
let reloadHandoffFailed = false;
|
|
225
205
|
let shutdownReason = 'shutdown';
|
|
226
206
|
const activationCloseFence = new SynchronousActivationCloseFence();
|
|
227
207
|
const taskManagerLoader = new LazyModule(() => import('./ui/background-tasks-manager.js'));
|
|
@@ -240,59 +220,22 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
240
220
|
},
|
|
241
221
|
shellPolicy,
|
|
242
222
|
tasksDir: config.tasksDir,
|
|
243
|
-
reloadShellOwner,
|
|
244
223
|
});
|
|
245
224
|
const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
|
|
246
225
|
events: pi.events,
|
|
247
226
|
registry,
|
|
248
227
|
});
|
|
249
228
|
|
|
250
|
-
const beginSessionShutdown = (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (pendingActivationClaim !== undefined) {
|
|
256
|
-
const claim = pendingActivationClaim;
|
|
257
|
-
pendingActivationClaim = undefined;
|
|
258
|
-
registry.abortReloadActivation(claim);
|
|
259
|
-
try {
|
|
260
|
-
reloadShellOwner.abortActivation(
|
|
261
|
-
claim,
|
|
262
|
-
new ReloadSurvivalError(
|
|
263
|
-
'pi_bg_reload_owner_stale_claim',
|
|
264
|
-
'activation shut down while its reload claim was staging',
|
|
265
|
-
),
|
|
266
|
-
);
|
|
267
|
-
} catch (error) {
|
|
268
|
-
handoffError = error;
|
|
269
|
-
}
|
|
270
|
-
} else if (reason === 'reload' && activationLease !== undefined) {
|
|
271
|
-
const lease = activationLease;
|
|
272
|
-
try {
|
|
273
|
-
registry.prepareReloadHandoff(lease);
|
|
274
|
-
activationLease = undefined;
|
|
275
|
-
activationIdentity = undefined;
|
|
276
|
-
} catch (error) {
|
|
277
|
-
reloadHandoffFailed = true;
|
|
278
|
-
handoffError = error;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
registry.setShuttingDown(true);
|
|
282
|
-
eventService.close();
|
|
283
|
-
}
|
|
284
|
-
// Teardown remains active on repeated calls so even a handle assigned by a
|
|
285
|
-
// racing continuation is still disposed rather than hidden by idempotence.
|
|
286
|
-
currentRegistryCtx = undefined;
|
|
287
|
-
if (handoffError !== undefined) throw handoffError;
|
|
229
|
+
const beginSessionShutdown = (): void => {
|
|
230
|
+
if (disposed) return;
|
|
231
|
+
disposed = true;
|
|
232
|
+
registry.setShuttingDown(true);
|
|
233
|
+
eventService.close();
|
|
288
234
|
};
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
// asynchronous cleanup. The core callback performs an eligible reload
|
|
292
|
-
// handoff first; every lazy lane then closes on the same call stack before
|
|
293
|
-
// Pi can await any later cleanup handler.
|
|
235
|
+
// Join the one synchronous all-lane barrier: every lazy lane closes on the
|
|
236
|
+
// same call stack before Pi can await any later cleanup handler.
|
|
294
237
|
activationCloseFence.add(() => {
|
|
295
|
-
beginSessionShutdown(
|
|
238
|
+
beginSessionShutdown();
|
|
296
239
|
});
|
|
297
240
|
activationCloseFence.add(() => {
|
|
298
241
|
taskManagerLoader.close();
|
|
@@ -302,72 +245,21 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
302
245
|
activationCloseFence.close();
|
|
303
246
|
});
|
|
304
247
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
248
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
249
|
+
// A Pi reload binds a fresh extension instance. Adoptions run before any
|
|
250
|
+
// task can start so notifications continue for tasks running across the
|
|
251
|
+
// reload, and the poller takes over ownership detection from disk records.
|
|
252
|
+
if (disposed) return;
|
|
253
|
+
registry.setShuttingDown(false);
|
|
254
|
+
await registry.ensureRuntimeDir(registryContext(ctx));
|
|
308
255
|
if (disposed) return;
|
|
309
|
-
const nextRegistryCtx = registryContext(ctx);
|
|
310
|
-
const identity = makeReloadShellIdentity(
|
|
311
|
-
nextRegistryCtx.sessionId ?? '',
|
|
312
|
-
realpathSync(ctx.cwd),
|
|
313
|
-
);
|
|
314
|
-
if (activationLease !== undefined && registry.hasCurrentReloadLease()) {
|
|
315
|
-
if (
|
|
316
|
-
activationIdentity?.hostPid !== identity.hostPid ||
|
|
317
|
-
activationIdentity.sessionId !== identity.sessionId ||
|
|
318
|
-
activationIdentity.cwdRealpath !== identity.cwdRealpath
|
|
319
|
-
) {
|
|
320
|
-
throw new ReloadSurvivalError(
|
|
321
|
-
'pi_bg_reload_owner_activation_conflict',
|
|
322
|
-
'a repeated session_start changed the bound reload owner identity without shutdown',
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
currentRegistryCtx = nextRegistryCtx;
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const activationNonce = randomBytes(16).toString('hex');
|
|
329
|
-
const claim = reloadShellOwner.beginActivation(identity, event.reason, activationNonce);
|
|
330
|
-
pendingActivationClaim = claim;
|
|
331
256
|
try {
|
|
332
|
-
|
|
333
|
-
if (pendingActivationClaim !== claim) {
|
|
334
|
-
// The synchronous shutdown barrier already aborted this claim and
|
|
335
|
-
// detached staged records while the durable import awaited.
|
|
336
|
-
registry.abortReloadActivation(claim);
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
if (disposed) {
|
|
340
|
-
registry.abortReloadActivation(claim);
|
|
341
|
-
pendingActivationClaim = undefined;
|
|
342
|
-
reloadShellOwner.abortActivation(
|
|
343
|
-
claim,
|
|
344
|
-
new ReloadSurvivalError(
|
|
345
|
-
'pi_bg_reload_owner_stale_claim',
|
|
346
|
-
'activation was disposed before reload claim commit',
|
|
347
|
-
),
|
|
348
|
-
);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const lease = reloadShellOwner.commitActivation(claim, adapter);
|
|
352
|
-
pendingActivationClaim = undefined;
|
|
353
|
-
activationLease = lease;
|
|
354
|
-
activationIdentity = identity;
|
|
355
|
-
currentRegistryCtx = nextRegistryCtx;
|
|
257
|
+
await registry.reconcileTasksFromDisk();
|
|
356
258
|
} catch (error) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
} catch (abortError) {
|
|
362
|
-
if (
|
|
363
|
-
typeof abortError !== 'object' ||
|
|
364
|
-
abortError === null ||
|
|
365
|
-
Reflect.get(abortError, 'code') !== 'pi_bg_reload_owner_stale_claim'
|
|
366
|
-
) {
|
|
367
|
-
throw new AggregateError([error, abortError], 'Reload activation claim and abort failed');
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
throw error;
|
|
259
|
+
console.error(
|
|
260
|
+
'[background-tasks] failed to reconcile background tasks from disk:',
|
|
261
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
262
|
+
);
|
|
371
263
|
}
|
|
372
264
|
});
|
|
373
265
|
|
|
@@ -376,9 +268,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
376
268
|
command: string,
|
|
377
269
|
options: StartTaskOptions = {},
|
|
378
270
|
): Promise<BgTask> {
|
|
379
|
-
|
|
380
|
-
currentRegistryCtx = nextRegistryCtx;
|
|
381
|
-
return registry.startTask(nextRegistryCtx, command, options);
|
|
271
|
+
return registry.startTask(registryContext(ctx), command, options);
|
|
382
272
|
}
|
|
383
273
|
|
|
384
274
|
async function openTaskManager(
|
|
@@ -433,7 +323,6 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
433
323
|
rerunTask: async (task: BackgroundTaskForUi) => {
|
|
434
324
|
const rerunOptions: StartTaskOptions = {
|
|
435
325
|
name: taskDisplayName(task),
|
|
436
|
-
surviveReload: task.surviveReload,
|
|
437
326
|
notifyOnCompletion: true,
|
|
438
327
|
triggerOnCompletion: false,
|
|
439
328
|
};
|
|
@@ -504,26 +393,12 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
504
393
|
},
|
|
505
394
|
);
|
|
506
395
|
|
|
507
|
-
pi.on('session_start', async (_event, ctx) => {
|
|
508
|
-
// Pi replacement binds a fresh extension instance. Never revive this old
|
|
509
|
-
// activation if a late lifecycle dispatch reaches it after shutdown.
|
|
510
|
-
if (disposed) return;
|
|
511
|
-
registry.setShuttingDown(false);
|
|
512
|
-
const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
|
|
513
|
-
currentRegistryCtx = nextRegistryCtx;
|
|
514
|
-
await registry.ensureRuntimeDir(nextRegistryCtx);
|
|
515
|
-
if (disposed) return;
|
|
516
|
-
});
|
|
517
|
-
|
|
518
396
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
397
|
+
if (shutdownReason.length === 0) shutdownReason = event.reason;
|
|
398
|
+
// Explicitly user-visible sessions always kill running tasks, while /reload,
|
|
399
|
+
// a Pi restart or a crash cannot reach this handler and thus never kill: the
|
|
400
|
+
// tasks survive and the next activation adopts them from disk.
|
|
522
401
|
try {
|
|
523
|
-
// Admission closure aborts cooperative preflight and the drain retains
|
|
524
|
-
// ownership until subprocess/file/managed cleanup settles. Any admitted
|
|
525
|
-
// child is inserted synchronously before spawn and is visible below.
|
|
526
|
-
await registry.waitForTaskAdmissions();
|
|
527
402
|
const running = registry.allTasks().filter((task) => task.status === 'running');
|
|
528
403
|
if (running.length === 0) return;
|
|
529
404
|
|
|
@@ -548,30 +423,15 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
548
423
|
}
|
|
549
424
|
} finally {
|
|
550
425
|
eventService.close();
|
|
551
|
-
if ((event.reason !== 'reload' || reloadHandoffFailed) && activationLease !== undefined) {
|
|
552
|
-
try {
|
|
553
|
-
await registry.waitForReloadHostSettlement();
|
|
554
|
-
} catch (error) {
|
|
555
|
-
console.error(
|
|
556
|
-
'[background-tasks] reload owner host settlement failed during shutdown:',
|
|
557
|
-
error,
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
registry.releaseReloadActivation(activationLease);
|
|
561
|
-
activationLease = undefined;
|
|
562
|
-
activationIdentity = undefined;
|
|
563
|
-
}
|
|
564
426
|
}
|
|
565
427
|
});
|
|
566
428
|
|
|
567
429
|
pi.registerCommand('bg', {
|
|
568
|
-
description:
|
|
569
|
-
'Start a tracked shell command: /bg [--survive-reload] [--name "Task name"] <command>',
|
|
430
|
+
description: 'Start a tracked shell command: /bg [--name "Task name"] <command>',
|
|
570
431
|
handler: async (args, ctx) => {
|
|
571
432
|
try {
|
|
572
433
|
const parsed = parseBgCommandArgs(args);
|
|
573
434
|
const taskOptions: StartTaskOptions = {
|
|
574
|
-
surviveReload: parsed.surviveReload,
|
|
575
435
|
notifyOnCompletion: true,
|
|
576
436
|
triggerOnCompletion: false,
|
|
577
437
|
};
|
|
@@ -747,12 +607,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
747
607
|
if (!args || typeof args !== 'object') throw new Error('bg_run arguments must be an object');
|
|
748
608
|
const input = args as BgToolArgumentRecord;
|
|
749
609
|
if (typeof input.command !== 'string') throw new Error('bg_run requires command string');
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
'pi_bg_survive_reload_invalid',
|
|
753
|
-
'bg_run surviveReload must be boolean when present',
|
|
754
|
-
);
|
|
755
|
-
}
|
|
610
|
+
// Legacy surviveReload values are silently ignored: task survival across
|
|
611
|
+
// /reload is now the only behavior, so the knob has nothing left to set.
|
|
756
612
|
const prepared: BgRunParamsValue = {
|
|
757
613
|
command: input.command,
|
|
758
614
|
name:
|
|
@@ -766,13 +622,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
|
|
|
766
622
|
prepared.notifyOnCompletion = input.notifyOnCompletion;
|
|
767
623
|
if (typeof input.triggerOnCompletion === 'boolean')
|
|
768
624
|
prepared.triggerOnCompletion = input.triggerOnCompletion;
|
|
769
|
-
if (typeof input.surviveReload === 'boolean') prepared.surviveReload = input.surviveReload;
|
|
770
625
|
return prepared;
|
|
771
626
|
},
|
|
772
627
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
773
628
|
const taskOptions: StartTaskOptions = {
|
|
774
629
|
name: params.name,
|
|
775
|
-
surviveReload: params.surviveReload ?? false,
|
|
776
630
|
notifyOnCompletion: params.notifyOnCompletion ?? true,
|
|
777
631
|
triggerOnCompletion: params.triggerOnCompletion ?? true,
|
|
778
632
|
};
|