pi-better-harness 0.41.1 → 0.41.3
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 +3 -2
- package/extensions/task-context/index.ts +8 -7
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/batch.ts +5 -2
- package/node_modules/pi-better-background-tasks/src/failures.ts +14 -6
- package/node_modules/pi-better-background-tasks/src/index.ts +14 -13
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +37 -10
- package/node_modules/pi-better-background-tasks/src/runtime.ts +99 -89
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +24 -3
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts +3 -1
- package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +30 -1
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/store.mjs +4 -2
- package/node_modules/pi-better-background-tasks/src/supervisor/store.ts +4 -2
- package/node_modules/pi-better-background-tasks/src/tools.ts +32 -24
- package/node_modules/pi-better-goal/README.md +2 -1
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/goal-state.ts +12 -2
- package/node_modules/pi-better-goal/src/index.ts +20 -85
- package/node_modules/pi-better-goal/src/permission-hold.ts +13 -26
- package/node_modules/pi-better-goal/src/types.ts +4 -5
- package/node_modules/pi-better-ssh/README.md +2 -0
- package/node_modules/pi-better-ssh/package.json +1 -1
- package/node_modules/pi-better-ssh/src/index.ts +4 -1
- package/node_modules/pi-better-ssh/src/mux-registry.ts +3 -1
- package/node_modules/pi-better-ssh/src/remote-bash.ts +4 -2
- package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +30 -1
- package/node_modules/pi-better-subagents/index.ts +14 -10
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +24 -3
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/validate.ts +3 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -45,8 +45,9 @@ existing saved labels remain unchanged until deliberately updated.
|
|
|
45
45
|
Use `newTask: true` with a new
|
|
46
46
|
outcome for unrelated work; a correction to the current task keeps its binding.
|
|
47
47
|
Hooks enable `update_todo`, `get_todo`, capture, matching Goal lifecycle bridge
|
|
48
|
-
writes,
|
|
49
|
-
|
|
48
|
+
writes, outcome updates, and automated cleanup. With hooks enabled, Harness records
|
|
49
|
+
a hidden, persistent outcome update at the next agent start only when its content
|
|
50
|
+
changes. It does not rebuild reminders before each model call. Turning hooks off stops those
|
|
50
51
|
hooks without deleting saved state or starting a turn. `/goal` and skills never
|
|
51
52
|
enable them.
|
|
52
53
|
|
|
@@ -677,13 +677,14 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
677
677
|
pi.on("session_tree", async (_event, ctx) => {
|
|
678
678
|
restore(ctx);
|
|
679
679
|
});
|
|
680
|
-
pi.on("
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
680
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
681
|
+
if (!enabled || !outcome) return;
|
|
682
|
+
const content = formatOutcome(outcome);
|
|
683
|
+
// These messages are persisted by Pi. Never rebuild or remove them in context.
|
|
684
|
+
const previous = [...ctx.sessionManager.getBranch()].reverse().find(entry =>
|
|
685
|
+
entry.type === "custom_message" && entry.customType === "pi-better-harness:outcome-update");
|
|
686
|
+
if (previous?.type === "custom_message" && previous.content === content) return;
|
|
687
|
+
return { message: { customType: "pi-better-harness:outcome-update", content, display: false } };
|
|
687
688
|
});
|
|
688
689
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
689
690
|
cancelCompletedPlanClear();
|
|
@@ -3,7 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { readTaskIntent } from "./failures.js";
|
|
4
4
|
import { validateCommandSpec } from "./process.js";
|
|
5
5
|
import { resolveSshCommand } from "./shared-ssh-core/index.js";
|
|
6
|
-
import { spawnTask, type ActiveSessionProvider, type SpawnTaskParams, type TaskRuntimeDependencies } from "./runtime.js";
|
|
6
|
+
import { spawnTask, type ActiveSessionProvider, type BackgroundTaskRuntime, type SpawnTaskParams, type TaskRuntimeDependencies } from "./runtime.js";
|
|
7
7
|
import type { BackgroundTaskCallbackOrigin } from "./types.js";
|
|
8
8
|
|
|
9
9
|
export interface SpawnBatchParams {
|
|
@@ -31,7 +31,10 @@ export function spawnTaskBatch(
|
|
|
31
31
|
callbackOrigin?: BackgroundTaskCallbackOrigin,
|
|
32
32
|
getActiveSession?: ActiveSessionProvider,
|
|
33
33
|
dependencies: TaskRuntimeDependencies = {},
|
|
34
|
+
runtime: Pick<BackgroundTaskRuntime, "spawnTask"> = { spawnTask },
|
|
34
35
|
): SpawnBatchResult {
|
|
36
|
+
// Jobs launch through the owning factory's runtime, never the default singleton.
|
|
37
|
+
const spawnOne = runtime.spawnTask;
|
|
35
38
|
if (!Array.isArray(params.jobs) || params.jobs.length < 1 || params.jobs.length > 100) {
|
|
36
39
|
throw new Error("jobs must contain between 1 and 100 background tasks.");
|
|
37
40
|
}
|
|
@@ -72,7 +75,7 @@ export function spawnTaskBatch(
|
|
|
72
75
|
};
|
|
73
76
|
for (const [index, job] of jobs.entries()) {
|
|
74
77
|
try {
|
|
75
|
-
const meta =
|
|
78
|
+
const meta = spawnOne(pi, { ...job, batchId: result.batchId, batchName: result.batchName }, defaultCwd, callbackOrigin, getActiveSession, dependencies);
|
|
76
79
|
result.launched.push({ job: index + 1, id: meta.id, ...(meta.name !== undefined ? { name: meta.name } : {}) });
|
|
77
80
|
} catch (error) {
|
|
78
81
|
result.failed.push({ job: index + 1, ...(job.name !== undefined ? { name: job.name } : {}), error: errorMessage(error) });
|
|
@@ -103,18 +103,19 @@ export function recoverDeclaredOperation(meta: BackgroundTaskMeta, at = Date.now
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
export function createFailureAttention() {
|
|
106
107
|
const attentionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
107
108
|
let attentionSuspended = false;
|
|
108
109
|
/** Session shutdown: stop this instance's attention timers; the next session_start reschedules (#324). */
|
|
109
|
-
|
|
110
|
+
function suspendFailureAttention(): void {
|
|
110
111
|
attentionSuspended = true;
|
|
111
112
|
for (const timer of attentionTimers.values()) clearTimeout(timer);
|
|
112
113
|
attentionTimers.clear();
|
|
113
114
|
}
|
|
114
|
-
|
|
115
|
+
function resumeFailureAttention(): void {
|
|
115
116
|
attentionSuspended = false;
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
function stopFailureAttention(id: string): void {
|
|
118
119
|
const timer = attentionTimers.get(id);
|
|
119
120
|
if (timer) clearTimeout(timer);
|
|
120
121
|
attentionTimers.delete(id);
|
|
@@ -125,7 +126,7 @@ export function stopFailureAttention(id: string): void {
|
|
|
125
126
|
* identity is per incident set; the inspect target is the real task id.
|
|
126
127
|
* Only the pending incidents are rows; earlier deliveries are counted, never repeated (#315).
|
|
127
128
|
*/
|
|
128
|
-
|
|
129
|
+
function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureState, pending: { key: string; incidents: string[] }) {
|
|
129
130
|
const rows = pendingAttentionRows(state, pending.incidents);
|
|
130
131
|
const note = pendingAttentionNote(state, pending.incidents);
|
|
131
132
|
const due = pending.incidents.length;
|
|
@@ -144,7 +145,7 @@ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureS
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
/** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
|
|
147
|
-
|
|
148
|
+
function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
|
|
148
149
|
stopFailureAttention(id);
|
|
149
150
|
if (attentionSuspended) return;
|
|
150
151
|
const meta = readMeta(id);
|
|
@@ -167,6 +168,7 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
|
|
|
167
168
|
return current.status !== "running" || failureAttentionHandled(now, pending.incidents);
|
|
168
169
|
},
|
|
169
170
|
getSuppressionReason: () => {
|
|
171
|
+
if (attentionSuspended) throw new Error("Failure attention instance is suspended; defer notification");
|
|
170
172
|
const current = readMeta(id);
|
|
171
173
|
if (!current) throw new Error("Task metadata is unavailable; defer failure notification");
|
|
172
174
|
if (current.status !== "running" || current.callback === false || current.stopRequestedAt) return "task is no longer running";
|
|
@@ -179,7 +181,7 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
|
|
|
179
181
|
onDelivered: (at) => { markFailureAttentionDelivered(failurePath(id), pending, at); },
|
|
180
182
|
});
|
|
181
183
|
void Promise.resolve(delivery).then((sent) => {
|
|
182
|
-
if (!sent && (!readMeta(id) || readMeta(id)?.status === "running")) {
|
|
184
|
+
if (!sent && !attentionSuspended && (!readMeta(id) || readMeta(id)?.status === "running")) {
|
|
183
185
|
const timer = setTimeout(() => scheduleFailureAttention(pi, id, getActiveSession), 1_000);
|
|
184
186
|
timer.unref();
|
|
185
187
|
attentionTimers.set(id, timer);
|
|
@@ -197,6 +199,12 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
return { suspendFailureAttention, resumeFailureAttention, stopFailureAttention, scheduleFailureAttention, failureAttentionFields };
|
|
203
|
+
}
|
|
204
|
+
/** Shared by the compatibility exports here and the legacy background runtime. */
|
|
205
|
+
export const defaultAttention = createFailureAttention();
|
|
206
|
+
export const { suspendFailureAttention, resumeFailureAttention, stopFailureAttention, scheduleFailureAttention, failureAttentionFields } = defaultAttention;
|
|
207
|
+
|
|
200
208
|
export function terminalFailureAttention(id: string) {
|
|
201
209
|
return pendingFailureAttention(readFailureState(failurePath(id)), Date.now(), { terminal: true });
|
|
202
210
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
|
|
3
2
|
import { initializeNavigatorPanels } from "./shared-navigator-panels.ts";
|
|
4
3
|
import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { createBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
5
|
+
import { createBackgroundTaskRuntime } from "./runtime.js";
|
|
7
6
|
import { createObservedWaitHost } from "./observed-waits.js";
|
|
8
7
|
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
9
8
|
import { observeForegroundSandboxPolicy } from "./sandbox.js";
|
|
@@ -11,6 +10,10 @@ import { registerTools } from "./tools.js";
|
|
|
11
10
|
import { deliverBackgroundTasksPrompt } from "./prompt.js";
|
|
12
11
|
|
|
13
12
|
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
13
|
+
// Each cached factory invocation owns its runtime, failure timers, navigator
|
|
14
|
+
// projection, and durable host. Nothing here is shared module state (#460).
|
|
15
|
+
const runtime = createBackgroundTaskRuntime();
|
|
16
|
+
const navigator = createBackgroundTasksNavigator(runtime);
|
|
14
17
|
// Created before registerTools so the tools can resolve it lazily; the durable
|
|
15
18
|
// store opens on session activation and closes on switch/unload.
|
|
16
19
|
const observedWaits = createObservedWaitHost(pi, {
|
|
@@ -41,29 +44,27 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
41
44
|
// would then carry a snapshot, so sections published after it never arrive.
|
|
42
45
|
pi.on("before_agent_start", (event) => deliverBackgroundTasksPrompt(event));
|
|
43
46
|
registerBackgroundTasksGoalProvider(pi);
|
|
44
|
-
ensureBackgroundTasksNavigatorProvider(pi);
|
|
47
|
+
navigator.ensureBackgroundTasksNavigatorProvider(pi);
|
|
45
48
|
// Subscribed at load so a sandbox extension that publishes later is heard, and
|
|
46
49
|
// asked for a snapshot in case one published before this extension existed.
|
|
47
50
|
observeForegroundSandboxPolicy(pi);
|
|
48
51
|
// Registered before registerTools(pi), so this runs before running tasks are resumed.
|
|
49
52
|
pi.on("session_start", async (_event, ctx) => {
|
|
50
|
-
resumeScheduledWork();
|
|
53
|
+
runtime.resumeScheduledWork();
|
|
51
54
|
registerBackgroundTasksGoalProvider(pi);
|
|
52
|
-
ensureBackgroundTasksNavigator(ctx);
|
|
55
|
+
navigator.ensureBackgroundTasksNavigator(ctx);
|
|
53
56
|
});
|
|
54
57
|
pi.on("session_before_switch", async () => {
|
|
55
58
|
observedWaits.close();
|
|
56
|
-
clearBackgroundTasksNavigatorSession();
|
|
57
|
-
disposeBackgroundWorkNavigator();
|
|
59
|
+
navigator.clearBackgroundTasksNavigatorSession();
|
|
58
60
|
});
|
|
59
61
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
60
62
|
// /reload, session replacement, and quit load a fresh instance; stop this one's timers (#324).
|
|
61
63
|
observedWaits.close();
|
|
62
|
-
suspendScheduledWork();
|
|
63
|
-
clearBackgroundTasksNavigatorSession();
|
|
64
|
-
disposeBackgroundWorkNavigator(ctx);
|
|
64
|
+
runtime.suspendScheduledWork();
|
|
65
|
+
navigator.clearBackgroundTasksNavigatorSession();
|
|
65
66
|
});
|
|
66
|
-
registerTools(pi);
|
|
67
|
+
registerTools(pi, runtime, navigator.refreshBackgroundTasksNavigator);
|
|
67
68
|
// Registered after registerTools so the callback batcher has its session
|
|
68
69
|
// context before a reconciled delivery can enqueue a parent notification.
|
|
69
70
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -75,4 +76,4 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
75
76
|
pi.on("session_tree", async (_event, ctx) => {
|
|
76
77
|
observedWaits.noteParentContext(ctx);
|
|
77
78
|
});
|
|
78
|
-
}
|
|
79
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_LOG_TAIL_ROWS as NAVIGATOR_DETAIL_ROWS,
|
|
4
|
+
disposeBackgroundWorkNavigator,
|
|
5
|
+
isNavigatorUiAvailable,
|
|
4
6
|
ensureBackgroundWorkNavigator,
|
|
5
7
|
refreshBackgroundWorkNavigator,
|
|
6
8
|
registerBackgroundWorkProvider,
|
|
@@ -15,16 +17,25 @@ import { failurePath, failureView } from "./failures.js";
|
|
|
15
17
|
import { readLog } from "./logs.js";
|
|
16
18
|
import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
|
|
17
19
|
import { getObservedWaitHost, type DurableTaskProjection } from "./observed-waits.js";
|
|
18
|
-
import { stopTask } from "./runtime.js";
|
|
20
|
+
import { stopTask, type BackgroundTaskRuntime } from "./runtime.js";
|
|
19
21
|
import { formatBackgroundTaskStall, observeBackgroundTaskStall } from "./stall.js";
|
|
20
22
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
|
|
21
23
|
|
|
24
|
+
const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
export function createBackgroundTasksNavigator(runtime: Pick<BackgroundTaskRuntime, "stopTask"> = { stopTask }) {
|
|
27
|
+
const owner = Symbol("background-tasks navigator factory");
|
|
22
28
|
let unregister: (() => void) | undefined;
|
|
29
|
+
let navigatorContext: ExtensionContext | undefined;
|
|
23
30
|
let piRef: ExtensionAPI | undefined;
|
|
24
31
|
let activeNavigatorOrigin: BackgroundTaskCallbackOrigin | undefined;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
function ensureBackgroundTasksNavigatorProvider(pi: ExtensionAPI): void {
|
|
33
|
+
// Provider registration is deferred until an actual TUI session owns it, so a
|
|
34
|
+
// headless child sharing the cached module cannot replace the parent's provider.
|
|
35
|
+
if (!navigatorContext) {
|
|
36
|
+
piRef = pi;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
28
39
|
// A reloaded or replaced extension instance gets a fresh host. Re-register so
|
|
29
40
|
// the provider's durable-change subscription targets that host, not the one
|
|
30
41
|
// captured by the previous instance.
|
|
@@ -37,22 +48,33 @@ export function ensureBackgroundTasksNavigatorProvider(pi: ExtensionAPI): void {
|
|
|
37
48
|
unregister = registerBackgroundWorkProvider(provider);
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
function ensureBackgroundTasksNavigator(ctx: ExtensionContext): void {
|
|
52
|
+
if (!isNavigatorUiAvailable(ctx)) return;
|
|
53
|
+
navigatorContext = ctx;
|
|
54
|
+
if (piRef) ensureBackgroundTasksNavigatorProvider(piRef);
|
|
41
55
|
activeNavigatorOrigin = getNavigatorOrigin(ctx);
|
|
42
56
|
ensureBackgroundWorkNavigator(ctx, {
|
|
43
57
|
createDefaultEditor: (tui, theme, keybindings) => new CustomEditor(tui as never, theme as never, keybindings as never),
|
|
44
58
|
isOpenTrigger: (data) => matchesKey(data, Key.left),
|
|
45
59
|
matchKey: (data, keyId) => matchesKey(data, keyId as Parameters<typeof matchesKey>[1]),
|
|
46
60
|
truncate: truncateToWidth,
|
|
47
|
-
});
|
|
61
|
+
}, owner);
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
|
|
64
|
+
function clearBackgroundTasksNavigatorSession(): void {
|
|
51
65
|
activeNavigatorOrigin = undefined;
|
|
66
|
+
unregister?.();
|
|
67
|
+
unregister = undefined;
|
|
68
|
+
if (navigatorContext) disposeBackgroundWorkNavigator(navigatorContext, owner);
|
|
69
|
+
navigatorContext = undefined;
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
function refreshBackgroundTasksNavigator(ctx?: ExtensionContext): void {
|
|
73
|
+
// This factory owns no navigator until a TUI session installs it; a refresh from
|
|
74
|
+
// a non-TUI tool context then repaints the stored owner context (main #332).
|
|
75
|
+
const ownerCtx = navigatorContext;
|
|
76
|
+
if (!ownerCtx) return;
|
|
77
|
+
refreshBackgroundWorkNavigator(ctx && isNavigatorUiAvailable(ctx) ? ctx : ownerCtx);
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
const provider: BackgroundWorkProvider = {
|
|
@@ -92,7 +114,7 @@ const provider: BackgroundWorkProvider = {
|
|
|
92
114
|
const meta = readMeta(id);
|
|
93
115
|
if (!meta) return { action: "missing", providerId: "background-tasks", id };
|
|
94
116
|
if (meta.status === "running") {
|
|
95
|
-
void stopTask(piRef as ExtensionAPI, id);
|
|
117
|
+
void runtime.stopTask(piRef as ExtensionAPI, id);
|
|
96
118
|
return { action: "stopped", providerId: "background-tasks", id, status: "stopping" };
|
|
97
119
|
}
|
|
98
120
|
meta.dismissedAt = Date.now();
|
|
@@ -288,6 +310,11 @@ function belongsToActiveNavigatorSession(meta: BackgroundTaskMeta): boolean {
|
|
|
288
310
|
return meta.cwd === active.cwd;
|
|
289
311
|
}
|
|
290
312
|
|
|
313
|
+
return { ensureBackgroundTasksNavigatorProvider, ensureBackgroundTasksNavigator, clearBackgroundTasksNavigatorSession, refreshBackgroundTasksNavigator };
|
|
314
|
+
}
|
|
315
|
+
const defaultNavigator = createBackgroundTasksNavigator();
|
|
316
|
+
export const { ensureBackgroundTasksNavigatorProvider, ensureBackgroundTasksNavigator, clearBackgroundTasksNavigatorSession, refreshBackgroundTasksNavigator } = defaultNavigator;
|
|
317
|
+
|
|
291
318
|
function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): boolean {
|
|
292
319
|
if (meta.status === "running") return false;
|
|
293
320
|
const endedAt = meta.endedAt;
|
|
@@ -10,7 +10,7 @@ import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.j
|
|
|
10
10
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
|
|
11
11
|
import { assertBackgroundExecutionAllowed, backgroundExecutionLimits, confineCommandSpec, isCommandTimeoutSeconds, resolveForegroundSandboxPlan, sandboxProvenance } from "./sandbox.js";
|
|
12
12
|
import { takeRecoverySnapshot } from "./shared-sandbox-core.js";
|
|
13
|
-
import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure,
|
|
13
|
+
import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, createFailureAttention, defaultAttention, terminalFailureAttention } from "./failures.js";
|
|
14
14
|
import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
|
|
15
15
|
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
16
16
|
import { formatCallbackFacts } from "./output.js";
|
|
@@ -29,6 +29,89 @@ import type {
|
|
|
29
29
|
import { observeTaskStallAttention } from "./stall-attention.js";
|
|
30
30
|
import { isTerminalStatus } from "./types.js";
|
|
31
31
|
|
|
32
|
+
export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
|
|
33
|
+
|
|
34
|
+
/** Structured intent (#325), declared by the launching agent and shared by spawns and watchers. */
|
|
35
|
+
export interface TaskIntentParams {
|
|
36
|
+
/** Stable id shared by modified retries of one operation. */
|
|
37
|
+
operation_id?: string | null;
|
|
38
|
+
/** Non-zero exit codes declared intentional before launch. */
|
|
39
|
+
expected_exit_codes?: number[] | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SpawnTaskParams extends CommandSpec, TaskIntentParams {
|
|
43
|
+
name?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Batch identity assigned by the batch launcher, never declared by a caller.
|
|
46
|
+
* Absent for a single spawn; carried into the persisted metadata so batch
|
|
47
|
+
* membership survives a reload instead of living only in the launch result.
|
|
48
|
+
*/
|
|
49
|
+
batchId?: string;
|
|
50
|
+
/** Batch label from the same launch, when the operator gave one. */
|
|
51
|
+
batchName?: string;
|
|
52
|
+
callback?: boolean;
|
|
53
|
+
timeout_seconds?: number;
|
|
54
|
+
max_log_bytes?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Opt-in step gate (#619): run the command under bash errexit, so the first
|
|
57
|
+
* unhandled failing step ends the task with that step's exit code and a later
|
|
58
|
+
* command cannot mask it.
|
|
59
|
+
*/
|
|
60
|
+
require_successful_steps?: boolean;
|
|
61
|
+
ssh?: SshConnectionParams;
|
|
62
|
+
remote?: RemoteTaskParams;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
|
|
66
|
+
name?: string;
|
|
67
|
+
callback?: boolean;
|
|
68
|
+
interval_seconds?: number;
|
|
69
|
+
timeout_seconds?: number;
|
|
70
|
+
max_log_bytes?: number;
|
|
71
|
+
success_when: Condition;
|
|
72
|
+
failure_when?: Condition;
|
|
73
|
+
/**
|
|
74
|
+
* Rejected (#619): a watch check is one polled command whose own exit code is the
|
|
75
|
+
* outcome, so the step gate cannot apply. Accepted in the shared schema only where
|
|
76
|
+
* the same wrapper also spawns.
|
|
77
|
+
*/
|
|
78
|
+
require_successful_steps?: boolean;
|
|
79
|
+
/** Consecutive blind checks before the watch is flagged (#359). Default 3; 0 turns it off. */
|
|
80
|
+
blind_checks?: number;
|
|
81
|
+
ssh?: SshConnectionParams;
|
|
82
|
+
remote?: RemoteTaskParams;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type WatchPollRunner = (timeoutMs?: number) => Promise<CommandResult>;
|
|
86
|
+
|
|
87
|
+
export interface TaskRuntimeDependencies {
|
|
88
|
+
remoteRunner?: RemoteRunner;
|
|
89
|
+
/**
|
|
90
|
+
* Embedding/test seam for a local watch's poll command. Production polls run
|
|
91
|
+
* the persisted command through `runCommandOnce`; a remote watch keeps using
|
|
92
|
+
* its SSH runner instead.
|
|
93
|
+
*/
|
|
94
|
+
watchPollRunner?: WatchPollRunner;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
98
|
+
/** Consecutive blind checks (exit 0, stderr, no condition matched) before a watch is flagged (#359). */
|
|
99
|
+
export const DEFAULT_BLIND_CHECKS = 3;
|
|
100
|
+
/** How long bg_task_watch waits for the first check before returning (#359). */
|
|
101
|
+
export const FIRST_WATCH_CHECK_WAIT_MS = 15_000;
|
|
102
|
+
/** How to fix a false alarm: some tools write progress or warnings to stderr on success. */
|
|
103
|
+
export const BLIND_CHECK_HINT = "If the stderr is expected (progress or warnings), redirect it (2>/dev/null) or set blind_checks:0.";
|
|
104
|
+
/**
|
|
105
|
+
* Why the launch stopped waiting before the first check finished (#359): the bounded wait
|
|
106
|
+
* ran out, the tool call was aborted (Esc), or the session shut down.
|
|
107
|
+
*/
|
|
108
|
+
export type FirstCheckWaitEnd = "timeout" | "aborted" | "suspended";
|
|
109
|
+
/** A first check's outcome, or why it is not known yet; undefined when the watch ended without one. */
|
|
110
|
+
export type FirstCheckOutcome = FirstWatchCheck | { pending: FirstCheckWaitEnd; waitedMs: number } | undefined;
|
|
111
|
+
|
|
112
|
+
/** Each cached extension factory owns its scheduling and suspension state. */
|
|
113
|
+
export function createBackgroundTaskRuntime(attention = createFailureAttention()) {
|
|
114
|
+
const { resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention } = attention;
|
|
32
115
|
const watcherTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
33
116
|
const remoteSessionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
34
117
|
const processTimeoutTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -49,22 +132,7 @@ const HANDOFF_LOST_GRACE_MS = 5_000;
|
|
|
49
132
|
let scheduledWorkSuspended = false;
|
|
50
133
|
const REMOTE_SESSION_POLL_MS = 100;
|
|
51
134
|
|
|
52
|
-
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
53
|
-
/** Consecutive blind checks (exit 0, stderr, no condition matched) before a watch is flagged (#359). */
|
|
54
|
-
export const DEFAULT_BLIND_CHECKS = 3;
|
|
55
|
-
/** How long bg_task_watch waits for the first check before returning (#359). */
|
|
56
|
-
export const FIRST_WATCH_CHECK_WAIT_MS = 15_000;
|
|
57
135
|
const BLIND_OPERATION = "watch-blind";
|
|
58
|
-
/** How to fix a false alarm: some tools write progress or warnings to stderr on success. */
|
|
59
|
-
export const BLIND_CHECK_HINT = "If the stderr is expected (progress or warnings), redirect it (2>/dev/null) or set blind_checks:0.";
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Why the launch stopped waiting before the first check finished (#359): the bounded wait
|
|
63
|
-
* ran out, the tool call was aborted (Esc), or the session shut down.
|
|
64
|
-
*/
|
|
65
|
-
export type FirstCheckWaitEnd = "timeout" | "aborted" | "suspended";
|
|
66
|
-
/** A first check's outcome, or why it is not known yet; undefined when the watch ended without one. */
|
|
67
|
-
export type FirstCheckOutcome = FirstWatchCheck | { pending: FirstCheckWaitEnd; waitedMs: number } | undefined;
|
|
68
136
|
|
|
69
137
|
/** In-flight first checks of watches launched by this instance, keyed by task id. */
|
|
70
138
|
const firstCheckWaiters = new Map<string, { promise: Promise<FirstWatchCheck | "suspended" | undefined>; resolve: (check: FirstWatchCheck | "suspended" | undefined) => void }>();
|
|
@@ -74,7 +142,7 @@ const firstCheckWaiters = new Map<string, { promise: Promise<FirstWatchCheck | "
|
|
|
74
142
|
* outcome when the wait ends first, and undefined when this instance did not launch the watch
|
|
75
143
|
* or the watch ended without a check.
|
|
76
144
|
*/
|
|
77
|
-
|
|
145
|
+
async function awaitFirstWatchCheck(id: string, timeoutMs = FIRST_WATCH_CHECK_WAIT_MS, signal?: AbortSignal): Promise<FirstCheckOutcome> {
|
|
78
146
|
const waiter = firstCheckWaiters.get(id);
|
|
79
147
|
if (!waiter) return undefined;
|
|
80
148
|
const started = Date.now();
|
|
@@ -132,7 +200,7 @@ function settleFirstCheck(id: string, check: FirstWatchCheck | "suspended" | und
|
|
|
132
200
|
* is recorded as terminal and its callback is delivered when its session resumes;
|
|
133
201
|
* after Pi quits, a resumed task whose process is gone is marked lost instead.
|
|
134
202
|
*/
|
|
135
|
-
|
|
203
|
+
function suspendScheduledWork(): void {
|
|
136
204
|
scheduledWorkSuspended = true;
|
|
137
205
|
for (const timer of watcherTimers.values()) clearTimeout(timer);
|
|
138
206
|
for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
|
|
@@ -150,76 +218,12 @@ export function suspendScheduledWork(): void {
|
|
|
150
218
|
}
|
|
151
219
|
|
|
152
220
|
/** Allow scheduling again; called at session_start before running tasks are resumed. */
|
|
153
|
-
|
|
221
|
+
function resumeScheduledWork(): void {
|
|
154
222
|
scheduledWorkSuspended = false;
|
|
155
223
|
resumeFailureAttention();
|
|
156
224
|
}
|
|
157
225
|
|
|
158
226
|
|
|
159
|
-
|
|
160
|
-
export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
|
|
161
|
-
|
|
162
|
-
/** Structured intent (#325), declared by the launching agent and shared by spawns and watchers. */
|
|
163
|
-
export interface TaskIntentParams {
|
|
164
|
-
/** Stable id shared by modified retries of one operation. */
|
|
165
|
-
operation_id?: string | null;
|
|
166
|
-
/** Non-zero exit codes declared intentional before launch. */
|
|
167
|
-
expected_exit_codes?: number[] | null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export interface SpawnTaskParams extends CommandSpec, TaskIntentParams {
|
|
171
|
-
name?: string;
|
|
172
|
-
/**
|
|
173
|
-
* Batch identity assigned by the batch launcher, never declared by a caller.
|
|
174
|
-
* Absent for a single spawn; carried into the persisted metadata so batch
|
|
175
|
-
* membership survives a reload instead of living only in the launch result.
|
|
176
|
-
*/
|
|
177
|
-
batchId?: string;
|
|
178
|
-
/** Batch label from the same launch, when the operator gave one. */
|
|
179
|
-
batchName?: string;
|
|
180
|
-
callback?: boolean;
|
|
181
|
-
timeout_seconds?: number;
|
|
182
|
-
max_log_bytes?: number;
|
|
183
|
-
/**
|
|
184
|
-
* Opt-in step gate (#619): run the command under bash errexit, so the first
|
|
185
|
-
* unhandled failing step ends the task with that step's exit code and a later
|
|
186
|
-
* command cannot mask it.
|
|
187
|
-
*/
|
|
188
|
-
require_successful_steps?: boolean;
|
|
189
|
-
ssh?: SshConnectionParams;
|
|
190
|
-
remote?: RemoteTaskParams;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
|
|
194
|
-
name?: string;
|
|
195
|
-
callback?: boolean;
|
|
196
|
-
interval_seconds?: number;
|
|
197
|
-
timeout_seconds?: number;
|
|
198
|
-
max_log_bytes?: number;
|
|
199
|
-
success_when: Condition;
|
|
200
|
-
failure_when?: Condition;
|
|
201
|
-
/**
|
|
202
|
-
* Rejected (#619): a watch check is one polled command whose own exit code is the
|
|
203
|
-
* outcome, so the step gate cannot apply. Accepted in the shared schema only where
|
|
204
|
-
* the same wrapper also spawns.
|
|
205
|
-
*/
|
|
206
|
-
require_successful_steps?: boolean;
|
|
207
|
-
/** Consecutive blind checks before the watch is flagged (#359). Default 3; 0 turns it off. */
|
|
208
|
-
blind_checks?: number;
|
|
209
|
-
ssh?: SshConnectionParams;
|
|
210
|
-
remote?: RemoteTaskParams;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export interface TaskRuntimeDependencies {
|
|
214
|
-
remoteRunner?: RemoteRunner;
|
|
215
|
-
/**
|
|
216
|
-
* Embedding/test seam for a local watch's poll command. Production polls run
|
|
217
|
-
* the persisted command through `runCommandOnce`; a remote watch keeps using
|
|
218
|
-
* its SSH runner instead.
|
|
219
|
-
*/
|
|
220
|
-
watchPollRunner?: WatchPollRunner;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
227
|
/**
|
|
224
228
|
* Persist the launch-time per-command cap with the task metadata. Reload and
|
|
225
229
|
* resume go through `readMeta`, so the value travels as ordinary JSON and the
|
|
@@ -286,9 +290,7 @@ function invalidPersistedCommandCap(meta: BackgroundTaskMeta): string | undefine
|
|
|
286
290
|
return `Persisted command time limit ${JSON.stringify(meta.commandTimeoutSeconds)} is invalid; the task was not resumed with an unlimited command.`;
|
|
287
291
|
}
|
|
288
292
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
export function spawnTask(
|
|
293
|
+
function spawnTask(
|
|
292
294
|
pi: ExtensionAPI,
|
|
293
295
|
params: SpawnTaskParams,
|
|
294
296
|
defaultCwd: string,
|
|
@@ -601,7 +603,7 @@ function failRemoteTask(
|
|
|
601
603
|
finalize(meta, { status: "failed", reason }, pi, getActiveSession);
|
|
602
604
|
}
|
|
603
605
|
|
|
604
|
-
|
|
606
|
+
function startWatchTask(
|
|
605
607
|
pi: ExtensionAPI,
|
|
606
608
|
params: WatchTaskParams,
|
|
607
609
|
defaultCwd: string,
|
|
@@ -698,7 +700,7 @@ function resolveWatchTimeoutSeconds(timeoutSeconds: number | undefined): number
|
|
|
698
700
|
return timeoutSeconds;
|
|
699
701
|
}
|
|
700
702
|
|
|
701
|
-
|
|
703
|
+
function resumeRunningTask(
|
|
702
704
|
pi: ExtensionAPI,
|
|
703
705
|
meta: BackgroundTaskMeta,
|
|
704
706
|
getActiveSession?: ActiveSessionProvider,
|
|
@@ -859,7 +861,7 @@ function resolvePersistedRemoteTask(
|
|
|
859
861
|
}, remoteRunner);
|
|
860
862
|
}
|
|
861
863
|
|
|
862
|
-
|
|
864
|
+
async function stopTask(
|
|
863
865
|
pi: ExtensionAPI,
|
|
864
866
|
id: string,
|
|
865
867
|
getActiveSession?: ActiveSessionProvider,
|
|
@@ -1608,3 +1610,11 @@ function observeWatchOutcome(meta: BackgroundTaskMeta, result: CommandResult): v
|
|
|
1608
1610
|
}
|
|
1609
1611
|
if (prior !== fingerprint) meta.lastProgressAt = meta.lastCheckedAt ?? Date.now();
|
|
1610
1612
|
}
|
|
1613
|
+
|
|
1614
|
+
return { awaitFirstWatchCheck, suspendScheduledWork, resumeScheduledWork, spawnTask, startWatchTask, resumeRunningTask, stopTask };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
export type BackgroundTaskRuntime = ReturnType<typeof createBackgroundTaskRuntime>;
|
|
1618
|
+
const defaultRuntime = createBackgroundTaskRuntime(defaultAttention);
|
|
1619
|
+
/** Compatibility for callers using the original singleton API. */
|
|
1620
|
+
export const { awaitFirstWatchCheck, suspendScheduledWork, resumeScheduledWork, spawnTask, startWatchTask, resumeRunningTask, stopTask } = defaultRuntime;
|
|
@@ -82,8 +82,10 @@ type HostDeps = {
|
|
|
82
82
|
|
|
83
83
|
type NavigatorState = {
|
|
84
84
|
providers: Map<string, BackgroundWorkProvider>;
|
|
85
|
+
registrationTokens?: Map<string, symbol>;
|
|
85
86
|
unsubscribers: Map<string, () => void>;
|
|
86
87
|
uiCtx?: ExtensionContext;
|
|
88
|
+
uiOwner?: symbol;
|
|
87
89
|
deps?: HostDeps;
|
|
88
90
|
lastHint?: string | null;
|
|
89
91
|
lastMainListLines?: string[];
|
|
@@ -137,6 +139,8 @@ function state(): NavigatorState {
|
|
|
137
139
|
|
|
138
140
|
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
139
141
|
const s = state();
|
|
142
|
+
const token = Symbol(provider.id);
|
|
143
|
+
(s.registrationTokens ??= new Map()).set(provider.id, token);
|
|
140
144
|
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
141
145
|
if (previousUnsub) {
|
|
142
146
|
try { previousUnsub(); } catch { /* ignore */ }
|
|
@@ -152,7 +156,10 @@ export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider)
|
|
|
152
156
|
s.unsubscribers.delete(provider.id);
|
|
153
157
|
}
|
|
154
158
|
refreshBackgroundWorkNavigator();
|
|
155
|
-
|
|
159
|
+
// A stale factory must not unregister its replacement from another cached module.
|
|
160
|
+
return () => {
|
|
161
|
+
if (state().registrationTokens?.get(provider.id) === token) unregisterBackgroundWorkProvider(provider.id);
|
|
162
|
+
};
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
export function unregisterBackgroundWorkProvider(id: string): void {
|
|
@@ -163,6 +170,7 @@ export function unregisterBackgroundWorkProvider(id: string): void {
|
|
|
163
170
|
}
|
|
164
171
|
s.unsubscribers.delete(id);
|
|
165
172
|
s.providers.delete(id);
|
|
173
|
+
s.registrationTokens?.delete(id);
|
|
166
174
|
refreshBackgroundWorkNavigator();
|
|
167
175
|
}
|
|
168
176
|
|
|
@@ -188,10 +196,11 @@ export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: stri
|
|
|
188
196
|
return hint ?? null;
|
|
189
197
|
}
|
|
190
198
|
|
|
191
|
-
export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps): void {
|
|
199
|
+
export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps, owner?: symbol): void {
|
|
192
200
|
if (!isNavigatorUiAvailable(ctx)) return;
|
|
193
201
|
const s = state();
|
|
194
202
|
s.uiCtx = ctx;
|
|
203
|
+
s.uiOwner = owner;
|
|
195
204
|
s.deps = {
|
|
196
205
|
...deps,
|
|
197
206
|
createTranscriptComponent: deps.createTranscriptComponent ?? s.deps?.createTranscriptComponent,
|
|
@@ -207,14 +216,26 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
207
216
|
refreshMainListWidget();
|
|
208
217
|
}
|
|
209
218
|
|
|
210
|
-
export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
219
|
+
export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext, owner?: symbol): void {
|
|
211
220
|
const s = state();
|
|
221
|
+
if (owner !== undefined) {
|
|
222
|
+
// Only the factory that installed the current navigator may dispose it.
|
|
223
|
+
if (s.uiOwner !== owner) return;
|
|
224
|
+
} else if (s.uiOwner !== undefined) {
|
|
225
|
+
// Ownership is never inferred: an omitted owner must not clear a navigator
|
|
226
|
+
// another cached factory (or a headless child) owns.
|
|
227
|
+
return;
|
|
228
|
+
} else if (ctx !== undefined) {
|
|
229
|
+
// Unowned navigators stay releasable by a live, matching UI context.
|
|
230
|
+
if (!isNavigatorUiAvailable(ctx) || (s.uiCtx && ctx.ui !== s.uiCtx.ui)) return;
|
|
231
|
+
}
|
|
212
232
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
213
233
|
s.dispose = undefined;
|
|
214
234
|
s.overlay = undefined;
|
|
215
235
|
stopMainListWidget();
|
|
216
236
|
const activeCtx = ctx ?? s.uiCtx;
|
|
217
237
|
s.uiCtx = undefined;
|
|
238
|
+
s.uiOwner = undefined;
|
|
218
239
|
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
219
240
|
try { setNavigatorPanel(activeCtx, MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
220
241
|
}
|