pi-better-harness 0.3.0 → 0.3.2
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/README.md +1 -1
- package/node_modules/pi-better-background-tasks/README.md +1 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/goal-provider.ts +3 -2
- package/node_modules/pi-better-background-tasks/src/maintenance.ts +96 -0
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +4 -2
- package/node_modules/pi-better-background-tasks/src/process-identity.ts +41 -0
- package/node_modules/pi-better-background-tasks/src/registry.ts +194 -2
- package/node_modules/pi-better-background-tasks/src/runtime.ts +10 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +19 -4
- package/node_modules/pi-better-background-tasks/src/tools.ts +5 -3
- package/node_modules/pi-better-background-tasks/src/types.ts +4 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +37 -5
- package/node_modules/pi-better-goal/src/subagents.ts +42 -4
- package/node_modules/pi-better-goal/src/types.ts +1 -0
- package/node_modules/pi-better-subagents/cleanup.ts +6 -2
- package/node_modules/pi-better-subagents/index.ts +56 -20
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +207 -3
- package/node_modules/pi-better-subagents/shared-navigator.ts +19 -4
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Use `pi-better-harness` when you want the full working set for Pi. It manages:
|
|
|
11
11
|
- `pi-better-background-tasks` for durable shell tasks and watchers.
|
|
12
12
|
- `pi-better-goal` for objective tracking that is aware of background work.
|
|
13
13
|
|
|
14
|
-
`pi-better-read-aloud`
|
|
14
|
+
`pi-better-plan` and `pi-better-read-aloud` are intentionally not included yet.
|
|
15
15
|
|
|
16
16
|
## Screenshots
|
|
17
17
|
|
|
@@ -15,6 +15,7 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
|
|
|
15
15
|
- Start long-running commands without blocking the current turn.
|
|
16
16
|
- Watch commands until success, failure, or timeout.
|
|
17
17
|
- Keep task metadata and logs available across reloads.
|
|
18
|
+
- Retain completed task artifacts for seven days, then remove them during rate-limited maintenance.
|
|
18
19
|
- Show active work in Pi's background-work navigator.
|
|
19
20
|
- Flag running tasks with no observable output or completed poll as stalled.
|
|
20
21
|
- Confine local task writes to the project directory when `pi-better-sandbox` is enabled.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { listMetas } from "./registry.js";
|
|
2
|
+
import { listMetas, listMetasForOrigin, onMetaChanged } from "./registry.js";
|
|
3
3
|
import type { BackgroundTaskMeta } from "./types.js";
|
|
4
4
|
import { isTerminalStatus } from "./types.js";
|
|
5
5
|
import { backgroundTaskProgressAt, observeBackgroundTaskStall } from "./stall.js";
|
|
@@ -46,7 +46,8 @@ export function registerBackgroundTasksGoalProvider(pi: ExtensionAPI): void {
|
|
|
46
46
|
pi.events?.emit(GOAL_REGISTER_PROVIDER_EVENT, {
|
|
47
47
|
id: "background-tasks",
|
|
48
48
|
label: "Background Tasks",
|
|
49
|
-
getActivity: (ctx: ExtensionContext) => collectBackgroundTaskGoalActivity(
|
|
49
|
+
getActivity: (ctx: ExtensionContext) => collectBackgroundTaskGoalActivity(listMetasForOrigin(getGoalActivityOrigin(ctx)), ctx),
|
|
50
|
+
onActivityChanged: onMetaChanged,
|
|
50
51
|
});
|
|
51
52
|
};
|
|
52
53
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { baseDir, listMetas, removeMeta, writeMeta } from "./registry.js";
|
|
4
|
+
import { processIdentityAlive as defaultProcessIdentityAlive } from "./process-identity.js";
|
|
5
|
+
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
6
|
+
import { isTerminalStatus } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_TASK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const STALE_MAINTENANCE_LOCK_MS = 10 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
interface MaintenanceState {
|
|
12
|
+
lastDate?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TaskMaintenanceOptions {
|
|
16
|
+
now?: number;
|
|
17
|
+
activeOrigin?: BackgroundTaskCallbackOrigin;
|
|
18
|
+
retentionMs?: number;
|
|
19
|
+
processIdentityAlive?: (pid: number | undefined, token?: string, recordedAt?: number) => boolean;
|
|
20
|
+
metas?: BackgroundTaskMeta[];
|
|
21
|
+
force?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TaskMaintenanceResult {
|
|
25
|
+
ran: boolean;
|
|
26
|
+
reconciled: number;
|
|
27
|
+
removed: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function runTaskMaintenance(options: TaskMaintenanceOptions = {}): TaskMaintenanceResult {
|
|
31
|
+
const now = options.now ?? Date.now();
|
|
32
|
+
const date = new Date(now).toISOString().slice(0, 10);
|
|
33
|
+
const statePath = join(baseDir(), "maintenance-state.json");
|
|
34
|
+
const lockPath = join(baseDir(), "maintenance.lock");
|
|
35
|
+
if (!options.force && readState(statePath).lastDate === date) return { ran: false, reconciled: 0, removed: 0 };
|
|
36
|
+
if (!options.force && !acquireMaintenanceLock(lockPath, now)) return { ran: false, reconciled: 0, removed: 0 };
|
|
37
|
+
|
|
38
|
+
let reconciled = 0;
|
|
39
|
+
let removed = 0;
|
|
40
|
+
try {
|
|
41
|
+
const identityAlive = options.processIdentityAlive ?? defaultProcessIdentityAlive;
|
|
42
|
+
const metas = options.metas ?? listMetas();
|
|
43
|
+
for (const meta of metas) {
|
|
44
|
+
if (meta.status !== "running" || belongsToOrigin(meta, options.activeOrigin)) continue;
|
|
45
|
+
if (identityAlive(meta.spawnPid, meta.spawnPidStartTime, meta.startedAt)) continue;
|
|
46
|
+
if (meta.remote?.session === "tmux") continue;
|
|
47
|
+
if (meta.kind === "process" && identityAlive(meta.pid, meta.pidStartTime, meta.startedAt)) continue;
|
|
48
|
+
meta.status = "failed";
|
|
49
|
+
meta.endedAt = now;
|
|
50
|
+
meta.error = "task supervisor is no longer alive; execution result is unavailable";
|
|
51
|
+
meta.result = { reason: meta.error };
|
|
52
|
+
writeMeta(meta);
|
|
53
|
+
reconciled += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cutoff = now - (options.retentionMs ?? DEFAULT_TASK_RETENTION_MS);
|
|
57
|
+
for (const meta of metas) {
|
|
58
|
+
if (!isTerminalStatus(meta.status)) continue;
|
|
59
|
+
if ((meta.endedAt ?? meta.startedAt) >= cutoff) continue;
|
|
60
|
+
if (removeMeta(meta)) removed += 1;
|
|
61
|
+
}
|
|
62
|
+
if (!options.force) writeFileSync(statePath, JSON.stringify({ lastDate: date } satisfies MaintenanceState));
|
|
63
|
+
return { ran: true, reconciled, removed };
|
|
64
|
+
} finally {
|
|
65
|
+
if (!options.force) rmSync(lockPath, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readState(path: string): MaintenanceState {
|
|
70
|
+
try { return JSON.parse(readFileSync(path, "utf8")) as MaintenanceState; } catch { return {}; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function acquireMaintenanceLock(path: string, now: number): boolean {
|
|
74
|
+
mkdirSync(baseDir(), { recursive: true });
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(path);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
try {
|
|
80
|
+
if (now - statSync(path).mtimeMs <= STALE_MAINTENANCE_LOCK_MS) return false;
|
|
81
|
+
rmSync(path, { recursive: true, force: true });
|
|
82
|
+
mkdirSync(path);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function belongsToOrigin(meta: BackgroundTaskMeta, active: BackgroundTaskCallbackOrigin | undefined): boolean {
|
|
91
|
+
if (!active) return false;
|
|
92
|
+
const origin = meta.callbackOrigin ?? { cwd: meta.cwd };
|
|
93
|
+
if (origin.cwd !== active.cwd) return false;
|
|
94
|
+
if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
12
|
import { readLog } from "./logs.js";
|
|
13
|
-
import {
|
|
13
|
+
import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
|
|
14
14
|
import { stopTask } from "./runtime.js";
|
|
15
15
|
import { observeBackgroundTaskStall } from "./stall.js";
|
|
16
16
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
|
|
@@ -67,7 +67,9 @@ const provider: BackgroundWorkProvider = {
|
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
function visibleMetas(now = Date.now()): BackgroundTaskMeta[] {
|
|
70
|
-
|
|
70
|
+
const active = activeNavigatorOrigin;
|
|
71
|
+
if (!active) return [];
|
|
72
|
+
return listMetasForOrigin(active).filter((meta) => meta.dismissedAt === undefined && belongsToActiveNavigatorSession(meta) && !isExpiredTerminalNavigatorRow(meta, now));
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function getNavigatorOrigin(ctx: ExtensionContext): BackgroundTaskCallbackOrigin {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const selfStartToken = readProcessStartToken(process.pid);
|
|
4
|
+
|
|
5
|
+
export function currentProcessStartToken(): string | undefined {
|
|
6
|
+
return selfStartToken;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readProcessStartToken(pid: number): string | undefined {
|
|
10
|
+
if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const output = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
15
|
+
timeout: 2_000,
|
|
16
|
+
}).trim();
|
|
17
|
+
return output || undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function processIdentityAlive(
|
|
24
|
+
pid: number | undefined,
|
|
25
|
+
recordedStartToken?: string,
|
|
26
|
+
recordedAt?: number,
|
|
27
|
+
): boolean {
|
|
28
|
+
if (!pid || pid <= 0) return false;
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
|
|
33
|
+
}
|
|
34
|
+
const current = readProcessStartToken(pid);
|
|
35
|
+
if (recordedStartToken && current) return recordedStartToken === current;
|
|
36
|
+
if (!recordedStartToken && current && recordedAt !== undefined) {
|
|
37
|
+
const currentStartedAt = Date.parse(current);
|
|
38
|
+
if (Number.isFinite(currentStartedAt) && currentStartedAt > recordedAt + 2_000) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -1,12 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import type { BackgroundTaskMeta } from "./types.js";
|
|
5
|
+
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
5
6
|
import { isTerminalStatus } from "./types.js";
|
|
6
7
|
|
|
7
8
|
let seq = 0;
|
|
8
9
|
const metaCache = new Map<string, BackgroundTaskMeta>();
|
|
10
|
+
// Owned snapshots are process-resident. A cheap directory signature catches
|
|
11
|
+
// cross-process index changes before any cached IDs are reused.
|
|
12
|
+
const indexIdsCache = new Map<string, { ids: Set<string>; signature: string }>();
|
|
13
|
+
const initializedIndexes = new Set<string>();
|
|
9
14
|
const metaChangedListeners = new Set<() => void>();
|
|
15
|
+
const registryIo = { fullDirectoryReads: 0, indexDirectoryReads: 0, metadataFileReads: 0, indexRevisionChecks: 0 };
|
|
16
|
+
|
|
17
|
+
export interface RegistryIoMetrics {
|
|
18
|
+
fullDirectoryReads: number;
|
|
19
|
+
indexDirectoryReads: number;
|
|
20
|
+
metadataFileReads: number;
|
|
21
|
+
indexRevisionChecks: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getRegistryIoMetrics(): RegistryIoMetrics {
|
|
25
|
+
return { ...registryIo };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resetRegistryIoMetrics(): void {
|
|
29
|
+
registryIo.fullDirectoryReads = 0;
|
|
30
|
+
registryIo.indexDirectoryReads = 0;
|
|
31
|
+
registryIo.metadataFileReads = 0;
|
|
32
|
+
registryIo.indexRevisionChecks = 0;
|
|
33
|
+
}
|
|
10
34
|
|
|
11
35
|
export function baseDir(): string {
|
|
12
36
|
const vitestPoolId = process.env.VITEST_POOL_ID;
|
|
@@ -56,6 +80,7 @@ export function writeMeta(meta: BackgroundTaskMeta): void {
|
|
|
56
80
|
ensureTaskDir(meta.id);
|
|
57
81
|
writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
|
|
58
82
|
metaCache.set(meta.id, meta);
|
|
83
|
+
indexMeta(meta);
|
|
59
84
|
for (const listener of metaChangedListeners) {
|
|
60
85
|
try { listener(); } catch { /* best effort */ }
|
|
61
86
|
}
|
|
@@ -68,6 +93,7 @@ export function onMetaChanged(listener: () => void): () => void {
|
|
|
68
93
|
|
|
69
94
|
export function readMeta(id: string): BackgroundTaskMeta | undefined {
|
|
70
95
|
try {
|
|
96
|
+
registryIo.metadataFileReads += 1;
|
|
71
97
|
const meta = JSON.parse(readFileSync(metaPathFor(id), "utf8")) as BackgroundTaskMeta;
|
|
72
98
|
metaCache.set(id, meta);
|
|
73
99
|
return meta;
|
|
@@ -77,9 +103,25 @@ export function readMeta(id: string): BackgroundTaskMeta | undefined {
|
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
105
|
|
|
106
|
+
export function removeMeta(meta: BackgroundTaskMeta): boolean {
|
|
107
|
+
try {
|
|
108
|
+
rmSync(taskDir(meta.id), { recursive: true, force: true });
|
|
109
|
+
metaCache.delete(meta.id);
|
|
110
|
+
removeIndexEntry(originIndexDir(originOf(meta)), meta.id);
|
|
111
|
+
removeIndexEntry(originActiveIndexDir(originOf(meta)), meta.id);
|
|
112
|
+
for (const listener of metaChangedListeners) {
|
|
113
|
+
try { listener(); } catch { /* best effort */ }
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
80
121
|
export function listMetas(): BackgroundTaskMeta[] {
|
|
81
122
|
let ids: string[];
|
|
82
123
|
try {
|
|
124
|
+
registryIo.fullDirectoryReads += 1;
|
|
83
125
|
ids = readdirSync(tasksDir());
|
|
84
126
|
} catch {
|
|
85
127
|
return [];
|
|
@@ -94,8 +136,158 @@ export function listMetas(): BackgroundTaskMeta[] {
|
|
|
94
136
|
.sort((a, b) => b.startedAt - a.startedAt);
|
|
95
137
|
}
|
|
96
138
|
|
|
139
|
+
export function listMetasForOrigin(origin: BackgroundTaskCallbackOrigin): BackgroundTaskMeta[] {
|
|
140
|
+
const directory = originIndexDir(origin);
|
|
141
|
+
ensureOriginIndex(origin, directory);
|
|
142
|
+
return readIndexIds(directory)
|
|
143
|
+
.map(readOwnedMeta)
|
|
144
|
+
.filter((meta): meta is BackgroundTaskMeta => meta !== undefined && belongsToOrigin(meta, origin))
|
|
145
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function listActiveMetasForOrigin(origin: BackgroundTaskCallbackOrigin): BackgroundTaskMeta[] {
|
|
149
|
+
const directory = originActiveIndexDir(origin);
|
|
150
|
+
ensureOriginActiveIndex(origin, directory);
|
|
151
|
+
return readIndexIds(directory)
|
|
152
|
+
.map(readOwnedMeta)
|
|
153
|
+
.filter((meta): meta is BackgroundTaskMeta => meta !== undefined && meta.status === "running" && belongsToOrigin(meta, origin))
|
|
154
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
155
|
+
}
|
|
156
|
+
|
|
97
157
|
function readMetaForSweep(id: string): BackgroundTaskMeta | undefined {
|
|
98
158
|
const cached = metaCache.get(id);
|
|
99
159
|
if (cached && isTerminalStatus(cached.status)) return cached;
|
|
100
160
|
return readMeta(id);
|
|
101
161
|
}
|
|
162
|
+
|
|
163
|
+
function readOwnedMeta(id: string): BackgroundTaskMeta | undefined {
|
|
164
|
+
return metaCache.get(id) ?? readMeta(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
|
|
168
|
+
return meta.callbackOrigin ?? { cwd: meta.cwd };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
|
|
172
|
+
const candidate = originOf(meta);
|
|
173
|
+
if (candidate.cwd !== origin.cwd) return false;
|
|
174
|
+
if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function originIndexDir(origin: BackgroundTaskCallbackOrigin): string {
|
|
179
|
+
return join(baseDir(), "by-origin", originKey(origin));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function originActiveIndexDir(origin: BackgroundTaskCallbackOrigin): string {
|
|
183
|
+
return join(baseDir(), "by-origin-active", originKey(origin));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function originKey(origin: BackgroundTaskCallbackOrigin): string {
|
|
187
|
+
return createHash("sha256")
|
|
188
|
+
.update(origin.cwd)
|
|
189
|
+
.update("\0")
|
|
190
|
+
.update(origin.sessionId ?? "")
|
|
191
|
+
.digest("hex")
|
|
192
|
+
.slice(0, 24);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function indexMeta(meta: BackgroundTaskMeta): void {
|
|
196
|
+
try {
|
|
197
|
+
const directory = originIndexDir(originOf(meta));
|
|
198
|
+
writeIndexEntry(directory, meta.id);
|
|
199
|
+
const activeDirectory = originActiveIndexDir(originOf(meta));
|
|
200
|
+
if (meta.status === "running") writeIndexEntry(activeDirectory, meta.id);
|
|
201
|
+
else removeIndexEntry(activeDirectory, meta.id);
|
|
202
|
+
} catch {
|
|
203
|
+
// Indexes are accelerators; meta.json remains authoritative.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function ensureOriginIndex(origin: BackgroundTaskCallbackOrigin, directory: string): void {
|
|
208
|
+
if (initializedIndexes.has(directory)) return;
|
|
209
|
+
try {
|
|
210
|
+
readFileSync(join(directory, ".initialized"));
|
|
211
|
+
initializedIndexes.add(directory);
|
|
212
|
+
return;
|
|
213
|
+
} catch {
|
|
214
|
+
// Existing registries are backfilled once for each session origin.
|
|
215
|
+
}
|
|
216
|
+
indexIdsCache.delete(directory);
|
|
217
|
+
const owned = listMetas().filter((meta) => belongsToOrigin(meta, origin));
|
|
218
|
+
mkdirSync(directory, { recursive: true });
|
|
219
|
+
for (const meta of owned) writeIndexEntry(directory, meta.id);
|
|
220
|
+
writeFileSync(join(directory, ".initialized"), "1");
|
|
221
|
+
initializedIndexes.add(directory);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ensureOriginActiveIndex(origin: BackgroundTaskCallbackOrigin, directory: string): void {
|
|
225
|
+
if (initializedIndexes.has(directory)) return;
|
|
226
|
+
try {
|
|
227
|
+
readFileSync(join(directory, ".initialized"));
|
|
228
|
+
initializedIndexes.add(directory);
|
|
229
|
+
return;
|
|
230
|
+
} catch {
|
|
231
|
+
// Existing registries are backfilled once for this origin's active set.
|
|
232
|
+
}
|
|
233
|
+
indexIdsCache.delete(directory);
|
|
234
|
+
const owned = listMetasForOrigin(origin).filter((meta) => meta.status === "running");
|
|
235
|
+
mkdirSync(directory, { recursive: true });
|
|
236
|
+
for (const meta of owned) writeIndexEntry(directory, meta.id);
|
|
237
|
+
writeFileSync(join(directory, ".initialized"), "1");
|
|
238
|
+
initializedIndexes.add(directory);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readIndexIds(directory: string): string[] {
|
|
242
|
+
const signature = indexDirectorySignature(directory);
|
|
243
|
+
const cached = indexIdsCache.get(directory);
|
|
244
|
+
if (cached && cached.signature === signature) return [...cached.ids];
|
|
245
|
+
try {
|
|
246
|
+
registryIo.indexDirectoryReads += 1;
|
|
247
|
+
const ids = new Set(readdirSync(directory).filter((id) => id !== ".initialized"));
|
|
248
|
+
indexIdsCache.set(directory, { ids, signature: indexDirectorySignature(directory) });
|
|
249
|
+
return [...ids];
|
|
250
|
+
} catch {
|
|
251
|
+
indexIdsCache.delete(directory);
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function writeIndexEntry(directory: string, id: string): void {
|
|
257
|
+
mkdirSync(directory, { recursive: true });
|
|
258
|
+
const cached = indexIdsCache.get(directory);
|
|
259
|
+
const cacheWasCurrent = cached ? cached.signature === indexDirectorySignature(directory) : false;
|
|
260
|
+
try {
|
|
261
|
+
writeFileSync(join(directory, id), "", { flag: "wx" });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
264
|
+
}
|
|
265
|
+
if (cached && cacheWasCurrent) {
|
|
266
|
+
cached.ids.add(id);
|
|
267
|
+
cached.signature = indexDirectorySignature(directory);
|
|
268
|
+
} else if (cached) {
|
|
269
|
+
indexIdsCache.delete(directory);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function removeIndexEntry(directory: string, id: string): void {
|
|
274
|
+
const cached = indexIdsCache.get(directory);
|
|
275
|
+
const cacheWasCurrent = cached ? cached.signature === indexDirectorySignature(directory) : false;
|
|
276
|
+
try { unlinkSync(join(directory, id)); } catch { /* stale index entries are harmless */ }
|
|
277
|
+
if (cached && cacheWasCurrent) {
|
|
278
|
+
cached.ids.delete(id);
|
|
279
|
+
cached.signature = indexDirectorySignature(directory);
|
|
280
|
+
} else if (cached) {
|
|
281
|
+
indexIdsCache.delete(directory);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function indexDirectorySignature(directory: string): string {
|
|
286
|
+
registryIo.indexRevisionChecks += 1;
|
|
287
|
+
try {
|
|
288
|
+
const stat = statSync(directory);
|
|
289
|
+
return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
290
|
+
} catch {
|
|
291
|
+
return "missing";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { appendLine, appendTaskOutput, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
|
|
4
4
|
import { evaluateCondition } from "./conditions.js";
|
|
5
5
|
import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
|
|
6
|
+
import { currentProcessStartToken, readProcessStartToken } from "./process-identity.js";
|
|
6
7
|
import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
|
|
7
8
|
import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
|
|
8
9
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
|
|
@@ -122,8 +123,10 @@ export function spawnTask(
|
|
|
122
123
|
launchArgv: launchArgvOf(commandSpec, launchSpec),
|
|
123
124
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
124
125
|
pid: spawned?.child.pid,
|
|
126
|
+
pidStartTime: spawned?.child.pid ? readProcessStartToken(spawned.child.pid) : undefined,
|
|
125
127
|
pgid: spawned?.pgid,
|
|
126
128
|
spawnPid: process.pid,
|
|
129
|
+
spawnPidStartTime: currentProcessStartToken(),
|
|
127
130
|
ssh: remoteTask?.metadata.ssh,
|
|
128
131
|
remote: remoteTask?.metadata.remote,
|
|
129
132
|
};
|
|
@@ -347,6 +350,7 @@ export function startWatchTask(
|
|
|
347
350
|
launchArgv: launchArgvOf(commandSpec, launchSpec),
|
|
348
351
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
349
352
|
spawnPid: process.pid,
|
|
353
|
+
spawnPidStartTime: currentProcessStartToken(),
|
|
350
354
|
successWhen: params.success_when,
|
|
351
355
|
failureWhen: params.failure_when,
|
|
352
356
|
notifyOn: "terminal",
|
|
@@ -379,6 +383,12 @@ export function resumeRunningTask(
|
|
|
379
383
|
return meta;
|
|
380
384
|
}
|
|
381
385
|
|
|
386
|
+
if (meta.spawnPid !== process.pid || meta.spawnPidStartTime !== currentProcessStartToken()) {
|
|
387
|
+
meta.spawnPid = process.pid;
|
|
388
|
+
meta.spawnPidStartTime = currentProcessStartToken();
|
|
389
|
+
writeMeta(meta);
|
|
390
|
+
}
|
|
391
|
+
|
|
382
392
|
const remoteTask = resolvePersistedRemoteTask(meta, dependencies.remoteRunner);
|
|
383
393
|
if (meta.kind === "command_watch") {
|
|
384
394
|
scheduleWatch(pi, meta.id, 0, getActiveSession, remoteTask
|
|
@@ -97,6 +97,7 @@ type NavigatorState = {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
100
|
+
const PLAN_NAVIGATION_KEY = Symbol.for("pi-better-harness.plan-navigation.state");
|
|
100
101
|
const FACTORY_MARK = "__piBetterHarnessNavigatorFactory";
|
|
101
102
|
const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
|
|
102
103
|
|
|
@@ -108,7 +109,7 @@ export const CLOSE_ARM_MS = 3000;
|
|
|
108
109
|
export const DEFAULT_LOG_TAIL_ROWS = 10;
|
|
109
110
|
export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
|
|
110
111
|
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
111
|
-
const
|
|
112
|
+
const DETAIL_OVERLAY_BOTTOM_MARGIN_ROWS = 0;
|
|
112
113
|
const EVIDENCE_SECTION_ID = "__evidence__";
|
|
113
114
|
const RUNNING_DOT_GLYPH = "●";
|
|
114
115
|
|
|
@@ -120,6 +121,14 @@ function state(): NavigatorState {
|
|
|
120
121
|
return g[GLOBAL_KEY]!;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
type PlanNavigationState = { visible: boolean; releaseWorkFocus?: () => void };
|
|
125
|
+
|
|
126
|
+
function planNavigationState(): PlanNavigationState {
|
|
127
|
+
const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: PlanNavigationState };
|
|
128
|
+
if (!global[PLAN_NAVIGATION_KEY]) global[PLAN_NAVIGATION_KEY] = { visible: false };
|
|
129
|
+
return global[PLAN_NAVIGATION_KEY]!;
|
|
130
|
+
}
|
|
131
|
+
|
|
123
132
|
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
124
133
|
const s = state();
|
|
125
134
|
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
@@ -161,7 +170,7 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
export function navigatorFooterHint(count: number): string | null {
|
|
164
|
-
return count > 0 ? `←
|
|
173
|
+
return count > 0 ? `← work · ${count}` : null;
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
@@ -185,6 +194,7 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
185
194
|
s.mainListRequestRender = undefined;
|
|
186
195
|
s.mainListDeadlineScheduler?.dispose();
|
|
187
196
|
s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
|
|
197
|
+
planNavigationState().releaseWorkFocus = unfocusMainList;
|
|
188
198
|
installNavigatorEditor(ctx.ui as any, deps);
|
|
189
199
|
s.lastHint = undefined;
|
|
190
200
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -211,7 +221,9 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
211
221
|
s.mainListDeadlineScheduler = undefined;
|
|
212
222
|
s.mainListWidgetInstalled = false;
|
|
213
223
|
s.mainListRequestRender = undefined;
|
|
224
|
+
s.editorComponent = undefined;
|
|
214
225
|
s.detailOverlayRows = undefined;
|
|
226
|
+
planNavigationState().releaseWorkFocus = undefined;
|
|
215
227
|
s.mainListSelectedId = undefined;
|
|
216
228
|
s.mainListFocused = false;
|
|
217
229
|
}
|
|
@@ -459,7 +471,10 @@ function buildMainListLines(
|
|
|
459
471
|
}
|
|
460
472
|
|
|
461
473
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
462
|
-
const
|
|
474
|
+
const plan = planNavigationState().visible ? " · → plan" : "";
|
|
475
|
+
const keys = focused
|
|
476
|
+
? `↑↓ switch · Enter detail · x stop${plan} · Esc unfocus`
|
|
477
|
+
: `← work navigator${plan}`;
|
|
463
478
|
return dim(keys, fg);
|
|
464
479
|
}
|
|
465
480
|
|
|
@@ -1025,7 +1040,7 @@ function buildTranscriptDetailLines(
|
|
|
1025
1040
|
}
|
|
1026
1041
|
|
|
1027
1042
|
function detailOverlayOptions() {
|
|
1028
|
-
const marginBottom =
|
|
1043
|
+
const marginBottom = DETAIL_OVERLAY_BOTTOM_MARGIN_ROWS;
|
|
1029
1044
|
return {
|
|
1030
1045
|
anchor: "top-left" as const,
|
|
1031
1046
|
width: "100%" as const,
|
|
@@ -3,8 +3,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
3
3
|
import { readLog } from "./logs.js";
|
|
4
4
|
import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
5
5
|
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
6
|
-
import { listMetas, readMeta, writeMeta } from "./registry.js";
|
|
6
|
+
import { listActiveMetasForOrigin, listMetas, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
7
7
|
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
8
|
+
import { runTaskMaintenance } from "./maintenance.js";
|
|
8
9
|
import { ForegroundSandboxBlockedError } from "./sandbox.js";
|
|
9
10
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
10
11
|
import { isTerminalStatus } from "./types.js";
|
|
@@ -108,7 +109,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
108
109
|
|
|
109
110
|
pi.on("session_start", async (_event, ctx) => {
|
|
110
111
|
activeSession = getCallbackOrigin(ctx);
|
|
111
|
-
for (const meta of
|
|
112
|
+
for (const meta of listActiveMetasForOrigin(activeSession)) resumeRunningTask(pi, meta, getActiveSession);
|
|
113
|
+
runTaskMaintenance({ activeOrigin: activeSession });
|
|
112
114
|
});
|
|
113
115
|
pi.on("session_before_switch", () => {
|
|
114
116
|
activeSession = undefined;
|
|
@@ -499,7 +501,7 @@ function formatClear(statuses: string[] | undefined, active: BackgroundTaskCallb
|
|
|
499
501
|
const wanted = statuses && statuses.length > 0 ? new Set(statuses) : undefined;
|
|
500
502
|
const now = Date.now();
|
|
501
503
|
let cleared = 0;
|
|
502
|
-
for (const meta of
|
|
504
|
+
for (const meta of listMetasForOrigin(active)) {
|
|
503
505
|
if (meta.dismissedAt !== undefined) continue;
|
|
504
506
|
if (!isTerminalStatus(meta.status)) continue;
|
|
505
507
|
if (wanted && !wanted.has(meta.status)) continue;
|
|
@@ -103,8 +103,12 @@ export interface BackgroundTaskMeta {
|
|
|
103
103
|
logDiscardedBytes?: number;
|
|
104
104
|
logRetentionEvents?: number;
|
|
105
105
|
pid?: number;
|
|
106
|
+
/** Opaque process-start token used to reject recycled child PIDs. */
|
|
107
|
+
pidStartTime?: string;
|
|
106
108
|
pgid?: number;
|
|
107
109
|
spawnPid: number;
|
|
110
|
+
/** Opaque process-start token used to reject recycled supervisor PIDs. */
|
|
111
|
+
spawnPidStartTime?: string;
|
|
108
112
|
successWhen?: Condition;
|
|
109
113
|
failureWhen?: Condition;
|
|
110
114
|
notifyOn?: "terminal";
|