pi-subagents 0.60.0 → 0.62.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/CHANGELOG.md +55 -0
- package/docs/agents.md +7 -3
- package/docs/configuration.md +9 -5
- package/docs/extension-api.md +14 -7
- package/docs/models.md +1 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +13 -4
- package/docs/workflows.md +14 -13
- package/install.mjs +1 -1
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +41 -11
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
- package/skills/pi-subagents/references/review-and-validation.md +1 -1
- package/src/agents/agent-management.ts +121 -63
- package/src/agents/agent-serializer.ts +3 -0
- package/src/agents/agents.ts +543 -223
- package/src/agents/runtime-agent-registry.ts +5 -1
- package/src/api/background-work.ts +7 -2
- package/src/api/external-runs.ts +67 -4
- package/src/api/preflight.ts +17 -8
- package/src/api/shared-types.ts +1 -0
- package/src/extension/index.ts +7 -4
- package/src/extension/public-execution.ts +48 -4
- package/src/extension/rpc.ts +62 -4
- package/src/extension/schemas.ts +17 -9
- package/src/extension/tool-description.ts +15 -19
- package/src/runs/background/active-async-capacity.ts +26 -7
- package/src/runs/background/async-execution.ts +98 -51
- package/src/runs/background/async-job-tracker.ts +62 -3
- package/src/runs/background/async-resume.ts +6 -3
- package/src/runs/background/async-status.ts +59 -9
- package/src/runs/background/auto-drain.ts +1 -1
- package/src/runs/background/fleet-view.ts +1 -1
- package/src/runs/background/process-terminal.ts +16 -0
- package/src/runs/background/result-watcher.ts +1 -1
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/run-status.ts +2 -2
- package/src/runs/background/scheduled-runs.ts +63 -6
- package/src/runs/background/steering.ts +4 -1
- package/src/runs/background/subagent-runner.ts +15 -9
- package/src/runs/background/subagent-wait.ts +20 -21
- package/src/runs/background/wait-completions.ts +1 -1
- package/src/runs/background/wait-tool.ts +18 -18
- package/src/runs/foreground/execution.ts +75 -9
- package/src/runs/foreground/subagent-executor.ts +181 -60
- package/src/runs/shared/acceptance.ts +113 -27
- package/src/runs/shared/capability-ceiling.ts +1 -0
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/host-step-status.ts +1 -0
- package/src/runs/shared/model-fallback.ts +61 -17
- package/src/runs/shared/parallel-utils.ts +2 -6
- package/src/runs/shared/permissions.ts +1 -1
- package/src/runs/shared/pi-args.ts +24 -12
- package/src/runs/shared/pi-spawn.ts +69 -35
- package/src/runs/shared/structured-output.ts +33 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +20 -3
- package/src/runs/shared/task-intent.ts +17 -6
- package/src/runs/shared/tool-timeout.ts +1 -1
- package/src/runs/shared/workflow-graph.ts +3 -2
- package/src/shared/atomic-json.ts +3 -1
- package/src/shared/fork-context.ts +0 -12
- package/src/shared/fork-session-cwd.ts +27 -0
- package/src/shared/launch-contract.ts +3 -0
- package/src/shared/types.ts +43 -4
- package/src/shared/workflow-child-permit.ts +91 -0
- package/src/slash/prompt-template-bridge.ts +37 -1
- package/src/slash/slash-commands.ts +18 -26
- package/src/slash/subagents-admin.ts +2 -0
- package/src/tui/render.ts +95 -52
- package/src/workflows/scripted-workflow.ts +153 -4
- package/src/workflows/workflow-child-summary.ts +1 -1
- package/src/workflows/workflow-receipt.ts +41 -4
- package/src/workflows/workflow-resources.ts +150 -0
|
@@ -73,7 +73,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
73
73
|
const steeringNoticeSeen = new Map<string, number>();
|
|
74
74
|
const jobWatchers = new Map<string, { watchers: Map<string, fs.FSWatcher>; retryTimer?: ReturnType<typeof setTimeout> }>();
|
|
75
75
|
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
76
|
+
let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
76
77
|
const runningJobIds = new Set<string>();
|
|
78
|
+
const externalJobBridgeRuns = new Set<string>();
|
|
79
|
+
const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
|
|
80
|
+
if (!Array.isArray(steps)) return "unknown";
|
|
81
|
+
for (const step of steps) {
|
|
82
|
+
const runner = (step as { runner?: unknown } | null)?.runner;
|
|
83
|
+
if (runner === undefined) continue;
|
|
84
|
+
if (!runner || typeof runner !== "object" || Array.isArray(runner)) return "unknown";
|
|
85
|
+
const runnerType = (runner as { type?: unknown }).type;
|
|
86
|
+
if (typeof runnerType !== "string") return "unknown";
|
|
87
|
+
if (runnerType === "external-job") return "required";
|
|
88
|
+
if (runnerType !== "pi" && runnerType !== "external-cli") return "unknown";
|
|
89
|
+
}
|
|
90
|
+
return "not-required";
|
|
91
|
+
};
|
|
77
92
|
let rootWatcher: fs.FSWatcher | undefined;
|
|
78
93
|
let nextLivenessAt = Date.now() + livenessIntervalMs;
|
|
79
94
|
let nextWidgetAnimationAt = Date.now() + WIDGET_ANIMATION_INTERVAL_MS;
|
|
@@ -94,9 +109,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
94
109
|
const rerenderLastWidget = (jobs = Array.from(state.asyncJobs.values())) => {
|
|
95
110
|
withLastUiContext((ctx) => rerenderWidget(ctx, jobs));
|
|
96
111
|
};
|
|
112
|
+
const scheduleLastWidgetRerender = () => {
|
|
113
|
+
if (widgetRerenderTimer) return;
|
|
114
|
+
widgetRerenderTimer = setTimeout(() => {
|
|
115
|
+
widgetRerenderTimer = undefined;
|
|
116
|
+
rerenderLastWidget();
|
|
117
|
+
}, EVENT_REFRESH_DEBOUNCE_MS);
|
|
118
|
+
widgetRerenderTimer.unref?.();
|
|
119
|
+
};
|
|
97
120
|
const requestLastWidgetRender = () => {
|
|
98
121
|
if (options.widgetEnabled === false) return;
|
|
99
|
-
|
|
122
|
+
withLastUiContext((ctx) => {
|
|
123
|
+
if (state.widgetsSuspended) return;
|
|
124
|
+
const requestRender = (ctx.ui as { requestRender?: () => void }).requestRender;
|
|
125
|
+
if (requestRender) requestRender.call(ctx.ui);
|
|
126
|
+
else renderWidget(ctx, Array.from(state.asyncJobs.values()));
|
|
127
|
+
});
|
|
100
128
|
};
|
|
101
129
|
const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
|
|
102
130
|
const restoredControlEventCursor = (asyncDir: string) => {
|
|
@@ -340,6 +368,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
340
368
|
if (timer) clearTimeout(timer);
|
|
341
369
|
refreshTimers.delete(asyncId);
|
|
342
370
|
runningJobIds.delete(asyncId);
|
|
371
|
+
externalJobBridgeRuns.delete(asyncId);
|
|
343
372
|
};
|
|
344
373
|
|
|
345
374
|
const refreshJob = (job: AsyncJobState): boolean => {
|
|
@@ -354,9 +383,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
354
383
|
console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
|
|
355
384
|
}
|
|
356
385
|
};
|
|
386
|
+
let bridgeSweepAttempted = false;
|
|
357
387
|
try {
|
|
358
388
|
emitNewControlEvents(job);
|
|
359
|
-
|
|
389
|
+
const bridgeAlreadyRequired = externalJobBridgeRuns.has(job.asyncId) || externalJobBridgeEligibility(job.steps) === "required";
|
|
390
|
+
if (bridgeAlreadyRequired) {
|
|
391
|
+
externalJobBridgeRuns.add(job.asyncId);
|
|
392
|
+
bridgeSweepAttempted = true;
|
|
393
|
+
serviceExternalJobBridgeRequests(job.asyncDir);
|
|
394
|
+
}
|
|
360
395
|
try {
|
|
361
396
|
if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
|
|
362
397
|
} catch (error) {
|
|
@@ -382,6 +417,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
382
417
|
},
|
|
383
418
|
});
|
|
384
419
|
const status = reconciliation.status ?? readStatus(job.asyncDir);
|
|
420
|
+
if (!bridgeAlreadyRequired) {
|
|
421
|
+
const bridgeEligibility = externalJobBridgeEligibility(status?.steps);
|
|
422
|
+
if (bridgeEligibility === "required") externalJobBridgeRuns.add(job.asyncId);
|
|
423
|
+
// Missing or ambiguous status retains the legacy sweep for recovery races.
|
|
424
|
+
if (bridgeEligibility !== "not-required") {
|
|
425
|
+
bridgeSweepAttempted = true;
|
|
426
|
+
serviceExternalJobBridgeRequests(job.asyncDir);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
385
429
|
if (status) {
|
|
386
430
|
const previousStatus = job.status;
|
|
387
431
|
job.status = status.state;
|
|
@@ -456,6 +500,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
456
500
|
runningJobIds.add(job.asyncId);
|
|
457
501
|
}
|
|
458
502
|
} catch (error) {
|
|
503
|
+
if (!bridgeSweepAttempted) {
|
|
504
|
+
try {
|
|
505
|
+
bridgeSweepAttempted = true;
|
|
506
|
+
serviceExternalJobBridgeRequests(job.asyncDir);
|
|
507
|
+
} catch (bridgeError) {
|
|
508
|
+
console.error(`Failed to service external job bridge for '${job.asyncDir}':`, bridgeError);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
459
511
|
if (job.status !== "failed") {
|
|
460
512
|
console.error(`Failed to read async status for '${job.asyncDir}':`, error);
|
|
461
513
|
job.status = "failed";
|
|
@@ -473,7 +525,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
473
525
|
const timer = setTimeout(() => {
|
|
474
526
|
refreshTimers.delete(asyncId);
|
|
475
527
|
const job = state.asyncJobs.get(asyncId);
|
|
476
|
-
if (job && refreshJob(job))
|
|
528
|
+
if (job && refreshJob(job)) scheduleLastWidgetRerender();
|
|
477
529
|
}, delayMs);
|
|
478
530
|
timer.unref?.();
|
|
479
531
|
refreshTimers.set(asyncId, timer);
|
|
@@ -604,6 +656,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
604
656
|
: rawAgents;
|
|
605
657
|
const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
|
|
606
658
|
state.liveAsyncSessionRoots?.delete(info.id);
|
|
659
|
+
externalJobBridgeRuns.delete(info.id);
|
|
607
660
|
state.asyncJobs.set(info.id, {
|
|
608
661
|
asyncId: info.id,
|
|
609
662
|
asyncDir,
|
|
@@ -677,11 +730,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
677
730
|
for (const asyncId of jobWatchers.keys()) closeJobWatcher(asyncId);
|
|
678
731
|
for (const timer of refreshTimers.values()) clearTimeout(timer);
|
|
679
732
|
refreshTimers.clear();
|
|
733
|
+
if (widgetRerenderTimer) clearTimeout(widgetRerenderTimer);
|
|
734
|
+
widgetRerenderTimer = undefined;
|
|
680
735
|
runningJobIds.clear();
|
|
736
|
+
externalJobBridgeRuns.clear();
|
|
681
737
|
};
|
|
682
738
|
|
|
683
739
|
const resetJobs = (ctx?: ExtensionContext) => {
|
|
684
740
|
dispose();
|
|
741
|
+
state.statusProjectionSessionId = null;
|
|
685
742
|
for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
|
|
686
743
|
state.cleanupTimers.clear();
|
|
687
744
|
state.asyncJobs.clear();
|
|
@@ -698,6 +755,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
698
755
|
|
|
699
756
|
const restoreActiveJobs = (ctx?: ExtensionContext) => {
|
|
700
757
|
if (ctx?.hasUI) state.lastUiContext = ctx;
|
|
758
|
+
state.statusProjectionSessionId = null;
|
|
701
759
|
if (!state.currentSessionId) return;
|
|
702
760
|
let runs: AsyncRunSummary[];
|
|
703
761
|
try {
|
|
@@ -713,6 +771,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
713
771
|
rememberFleetJob(state, job);
|
|
714
772
|
watchJob(job);
|
|
715
773
|
}
|
|
774
|
+
state.statusProjectionSessionId = state.currentSessionId;
|
|
716
775
|
if (runs.length === 0) return;
|
|
717
776
|
ensurePoller();
|
|
718
777
|
rerenderLastWidget();
|
|
@@ -314,8 +314,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
314
314
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
|
|
315
315
|
const parsed = value as Record<string, unknown>;
|
|
316
316
|
const allowedFields = new Set([
|
|
317
|
-
"version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
|
|
318
|
-
"subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
|
|
317
|
+
"version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
|
|
318
|
+
"subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
|
|
319
319
|
"skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
|
|
320
320
|
"artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
|
|
321
321
|
"launchResolvedExtensions", "runFanoutBudget", "lane",
|
|
@@ -347,6 +347,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
347
347
|
if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
|
|
348
348
|
if (parsed.context !== undefined && parsed.context !== "fresh" && parsed.context !== "fork") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': context is invalid.`);
|
|
349
349
|
if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
|
|
350
|
+
if (parsed.modelOrigin !== undefined && parsed.modelOrigin !== "explicit" && parsed.modelOrigin !== "inherited" && parsed.modelOrigin !== "configured") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOrigin must be 'explicit', 'inherited', or 'configured'.`);
|
|
351
|
+
if (parsed.modelOrigin === undefined && parsed.model !== undefined) parsed.modelOrigin = parsed.modelOverrideFromParent ? "inherited" : "configured";
|
|
350
352
|
for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
|
|
351
353
|
if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
|
|
352
354
|
}
|
|
@@ -354,7 +356,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
354
356
|
else if (typeof parsed.inheritGlobalContext !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': inheritGlobalContext must be a boolean.`);
|
|
355
357
|
if (parsed.allowNestedSubagents !== undefined && typeof parsed.allowNestedSubagents !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': allowNestedSubagents must be a boolean.`);
|
|
356
358
|
if (!Number.isInteger(parsed.maxSubagentDepth) || (parsed.maxSubagentDepth as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxSubagentDepth must be a non-negative integer.`);
|
|
357
|
-
for (const field of ["fallbackModels", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
|
|
359
|
+
for (const field of ["fallbackModels", "tools", "excludeTools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
|
|
358
360
|
const item = parsed[field];
|
|
359
361
|
if (item !== undefined && (!Array.isArray(item) || item.some((entry) => typeof entry !== "string" || !entry.trim()))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must contain non-empty strings.`);
|
|
360
362
|
}
|
|
@@ -588,6 +590,7 @@ export function applySteeringRecoveryAgentConfig(agentConfig: AgentConfig, descr
|
|
|
588
590
|
thinking: descriptor.thinking,
|
|
589
591
|
maxThinking: intersectThinkingCeilings(descriptor.thinkingCeiling, agentConfig.maxThinking),
|
|
590
592
|
tools: descriptor.tools ? [...descriptor.tools] : undefined,
|
|
593
|
+
excludeTools: descriptor.excludeTools ? [...descriptor.excludeTools] : undefined,
|
|
591
594
|
allowNestedSubagents: descriptor.allowNestedSubagents,
|
|
592
595
|
extensions: descriptor.extensions ? [...descriptor.extensions] : undefined,
|
|
593
596
|
subagentOnlyExtensions: descriptor.subagentOnlyExtensions ? [...descriptor.subagentOnlyExtensions] : undefined,
|
|
@@ -166,6 +166,40 @@ function getErrorMessage(error: unknown): string {
|
|
|
166
166
|
return error instanceof Error ? error.message : String(error);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
function isAsyncStatusIsolationError(asyncDir: string, error: unknown): boolean {
|
|
170
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
171
|
+
const message = getErrorMessage(error);
|
|
172
|
+
return /^(?:Failed to (?:inspect|read|parse|validate) async status file|Invalid async status file) '/.test(message)
|
|
173
|
+
|| message.startsWith(`Invalid async status '${statusPath}'`)
|
|
174
|
+
|| message.startsWith(`Invalid host step '${statusPath}`)
|
|
175
|
+
|| /^(workflowChildren|Invalid workflowChildren)/.test(message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isolateCorruptActiveRun(asyncDir: string, runId: string, error: unknown, now?: () => number): void {
|
|
179
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
180
|
+
const processTerminal = readProcessTerminal(asyncDir, { runId });
|
|
181
|
+
let markerAge: number | undefined;
|
|
182
|
+
try {
|
|
183
|
+
markerAge = activeRunMarkerAgeMs(asyncDir, now?.());
|
|
184
|
+
} catch (markerError) {
|
|
185
|
+
console.error(`Failed to inspect corrupt async active-run marker for '${runId}':`, markerError);
|
|
186
|
+
}
|
|
187
|
+
const markerCanBeReleased = processTerminal?.state === "observed"
|
|
188
|
+
|| (markerAge !== undefined && markerAge > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS);
|
|
189
|
+
let markerAction = "active marker retained because runner liveness is unknown";
|
|
190
|
+
if (markerCanBeReleased) {
|
|
191
|
+
try {
|
|
192
|
+
releaseActiveRunIndex(asyncDir);
|
|
193
|
+
markerAction = processTerminal?.state === "observed"
|
|
194
|
+
? "active marker released after observed process-terminal proof"
|
|
195
|
+
: "stale active marker released";
|
|
196
|
+
} catch (releaseError) {
|
|
197
|
+
markerAction = `failed to release active marker: ${getErrorMessage(releaseError)}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
console.error(`[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
function isNotFoundError(error: unknown): boolean {
|
|
170
204
|
return typeof error === "object"
|
|
171
205
|
&& error !== null
|
|
@@ -250,14 +284,16 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
|
|
|
250
284
|
}
|
|
251
285
|
|
|
252
286
|
function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
|
|
253
|
-
|
|
287
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
288
|
+
validateAsyncStatusLaneMetadata(status, `Invalid async status '${statusPath}'`);
|
|
254
289
|
const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
|
|
255
|
-
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${
|
|
256
|
-
assertWorkflowGraphHostSteps(status.workflowGraph,
|
|
290
|
+
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${statusPath}': workflowChildren.workflowRunId does not match.`);
|
|
291
|
+
assertWorkflowGraphHostSteps(status.workflowGraph, statusPath, status.runId);
|
|
257
292
|
const hostSteps = validHostStepNodes(status.workflowGraph);
|
|
258
293
|
if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
|
|
259
|
-
throw new Error(`Invalid async status '${
|
|
294
|
+
throw new Error(`Invalid async status '${statusPath}': sessionId must be a string.`);
|
|
260
295
|
}
|
|
296
|
+
if (status.outputFile !== undefined && typeof status.outputFile !== "string") throw new Error(`Invalid async status '${statusPath}': outputFile must be a string.`);
|
|
261
297
|
const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
|
|
262
298
|
const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
|
|
263
299
|
?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
|
|
@@ -499,10 +535,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
499
535
|
};
|
|
500
536
|
for (const entry of entries) {
|
|
501
537
|
const asyncDir = path.join(asyncDirRoot, entry);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
538
|
+
let status: (AsyncStatus & { cwd?: string }) | null;
|
|
539
|
+
try {
|
|
540
|
+
const reconciliation = options.reconcile === false
|
|
541
|
+
? undefined
|
|
542
|
+
: reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
|
|
543
|
+
status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
|
|
546
|
+
isolateCorruptActiveRun(asyncDir, entry, error, options.now);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
506
549
|
if (!status) {
|
|
507
550
|
if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
|
|
508
551
|
continue;
|
|
@@ -528,7 +571,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
528
571
|
nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
|
|
529
572
|
}
|
|
530
573
|
}
|
|
531
|
-
|
|
574
|
+
let summary: AsyncRunSummary;
|
|
575
|
+
try {
|
|
576
|
+
summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
|
|
579
|
+
isolateCorruptActiveRun(asyncDir, entry, error, options.now);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
532
582
|
runs.push(summary);
|
|
533
583
|
}
|
|
534
584
|
|
|
@@ -62,7 +62,7 @@ export async function drainOutstandingWork(deps: AutoDrainDeps): Promise<void> {
|
|
|
62
62
|
},
|
|
63
63
|
);
|
|
64
64
|
if (waitResult.isError) {
|
|
65
|
-
throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "
|
|
65
|
+
throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -330,7 +330,7 @@ function formatDetachedForegroundFleetLines(runs: ForegroundRun[]): string[] {
|
|
|
330
330
|
const childSummary = detachedChildren.map((child) => `${fleetChildDisplayName(child)} #${child.index}`).join(", ");
|
|
331
331
|
lines.push(`- ${run.runId} | detached | ${run.mode}${childSummary ? ` | ${childSummary}` : ""}`);
|
|
332
332
|
lines.push(` status: subagent({ action: "status", id: "${run.runId}" })`);
|
|
333
|
-
lines.push(` recovery: reply to the supervisor request first, then wait with
|
|
333
|
+
lines.push(` recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
|
|
334
334
|
}
|
|
335
335
|
return lines;
|
|
336
336
|
}
|
|
@@ -115,6 +115,22 @@ export function writeProcessTerminalCandidate(asyncDir: string, candidate: Proce
|
|
|
115
115
|
writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Establish ownership before authorizing a runner to launch any child process. */
|
|
119
|
+
export function initializeProcessTerminal(asyncDir: string, runId: string, runnerProcessInstanceId: string): void {
|
|
120
|
+
writeProcessTerminalCandidate(asyncDir, {
|
|
121
|
+
version: 1,
|
|
122
|
+
runId,
|
|
123
|
+
runnerProcessInstanceId,
|
|
124
|
+
writers: {},
|
|
125
|
+
});
|
|
126
|
+
writeAtomicJson(processTerminalPath(asyncDir), {
|
|
127
|
+
version: 1,
|
|
128
|
+
state: "pending",
|
|
129
|
+
runId,
|
|
130
|
+
runnerProcessInstanceId,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token: string, acknowledged: boolean): void {
|
|
119
135
|
const candidate = readProcessTerminalCandidate(asyncDir);
|
|
120
136
|
if (!candidate || candidate.revivalLeaseToken !== token) return;
|
|
@@ -426,7 +426,7 @@ export function createResultWatcher(
|
|
|
426
426
|
if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
|
|
427
427
|
const epoch = deliveryEpoch;
|
|
428
428
|
if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
|
|
429
|
-
// Recorded before dedupe and before the unlink below so
|
|
429
|
+
// Recorded before dedupe and before the unlink below so bg_wait can
|
|
430
430
|
// use the in-memory record or its bounded durable replay after cleanup.
|
|
431
431
|
recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
|
|
432
432
|
resultsDir,
|
|
@@ -12,7 +12,7 @@ function isIntercomDetached(run: AsyncRunSummary): boolean {
|
|
|
12
12
|
|
|
13
13
|
function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined {
|
|
14
14
|
if (!isIntercomDetached(run)) return undefined;
|
|
15
|
-
return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with
|
|
15
|
+
return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with bg_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
|
|
@@ -128,7 +128,7 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
|
|
|
128
128
|
const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
|
|
129
129
|
if (workflowChildren.length > 0) {
|
|
130
130
|
return [
|
|
131
|
-
...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with
|
|
131
|
+
...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with bg_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
|
|
132
132
|
...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
|
|
133
133
|
].join("\n");
|
|
134
134
|
}
|
|
@@ -228,7 +228,7 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
|
|
|
228
228
|
const detached = run.children.some((child) => child.status === "detached");
|
|
229
229
|
const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
|
|
230
230
|
if (detached) {
|
|
231
|
-
lines.push(`Recovery: reply to the supervisor request first, then wait with
|
|
231
|
+
lines.push(`Recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
|
|
232
232
|
} else if (resumable) {
|
|
233
233
|
lines.push(run.children.length === 1
|
|
234
234
|
? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
|
|
@@ -52,6 +52,8 @@ export interface ScheduleRecord {
|
|
|
52
52
|
catchUp: "none" | "latest";
|
|
53
53
|
timeoutMs?: number;
|
|
54
54
|
paused: boolean;
|
|
55
|
+
sessionOnly?: boolean;
|
|
56
|
+
ownerSessionFile?: string;
|
|
55
57
|
createdAt: string;
|
|
56
58
|
updatedAt: string;
|
|
57
59
|
activeRunId?: string;
|
|
@@ -72,6 +74,8 @@ export interface ScheduleRunRecord {
|
|
|
72
74
|
error?: string;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
type PublicScheduleRecord = Omit<ScheduleRecord, "ownerSessionFile">;
|
|
78
|
+
|
|
75
79
|
type ScheduledRunManagerDeps = {
|
|
76
80
|
config: ExtensionConfig;
|
|
77
81
|
launch(params: SubagentParamsLike, ctx: ExtensionContext, signal: AbortSignal): Promise<AgentToolResult<Details>>;
|
|
@@ -295,6 +299,8 @@ function parseSchedule(value: unknown, file: string): ScheduleRecord {
|
|
|
295
299
|
} else if (record.trigger.kind === "interval") {
|
|
296
300
|
if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string") throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
|
|
297
301
|
} else throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
|
|
302
|
+
if (record.sessionOnly !== undefined && typeof record.sessionOnly !== "boolean") throw new Error(`Schedule record '${file}' has invalid sessionOnly.`);
|
|
303
|
+
if (record.sessionOnly === true && (typeof record.ownerSessionFile !== "string" || !record.ownerSessionFile.trim())) throw new Error(`Schedule record '${file}' is session-only but has no owner session file.`);
|
|
298
304
|
return { ...record, target: parseScheduleTarget(record.target, file) } as ScheduleRecord;
|
|
299
305
|
}
|
|
300
306
|
|
|
@@ -399,13 +405,19 @@ function duePlannedAt(schedule: ScheduleRecord, now: number): number | undefined
|
|
|
399
405
|
}
|
|
400
406
|
|
|
401
407
|
function textResult(text: string, schedules?: ScheduleRecord[], runs?: ScheduleRunRecord[], isError = false): AgentToolResult<Details> {
|
|
408
|
+
const publicSchedules = schedules?.map(publicScheduleRecord);
|
|
402
409
|
return {
|
|
403
410
|
content: [{ type: "text", text }],
|
|
404
411
|
...(isError ? { isError: true } : {}),
|
|
405
|
-
details: { mode: "management", results: [], schedules: { ...(
|
|
412
|
+
details: { mode: "management", results: [], schedules: { ...(publicSchedules ? { records: publicSchedules } : {}), ...(runs ? { runs } : {}) } },
|
|
406
413
|
};
|
|
407
414
|
}
|
|
408
415
|
|
|
416
|
+
function publicScheduleRecord(schedule: ScheduleRecord): PublicScheduleRecord {
|
|
417
|
+
const { ownerSessionFile: _ownerSessionFile, ...rest } = schedule;
|
|
418
|
+
return rest;
|
|
419
|
+
}
|
|
420
|
+
|
|
409
421
|
function targetLabel(target: ScheduleTarget): string {
|
|
410
422
|
const preview = previewSimpleWorkflowRun(target.workflowScript);
|
|
411
423
|
return preview?.agent ? `workflowScript -> agent ${preview.agent}` : "workflowScript (dynamic)";
|
|
@@ -450,6 +462,32 @@ function snapshotContext(ctx: ExtensionContext, cwd: string): ExtensionContext {
|
|
|
450
462
|
return { ...ctx, cwd, sessionManager };
|
|
451
463
|
}
|
|
452
464
|
|
|
465
|
+
/**
|
|
466
|
+
* 规范化会话文件路径, 兼容 Windows 路径大小写差异.
|
|
467
|
+
*
|
|
468
|
+
* @param value 会话文件路径
|
|
469
|
+
* @returns 规范化后的路径, 空值时返回 undefined
|
|
470
|
+
*/
|
|
471
|
+
function normalizedSessionFile(value: string | undefined): string | undefined {
|
|
472
|
+
if (!value || !value.trim()) return undefined;
|
|
473
|
+
const normalized = path.normalize(path.resolve(value));
|
|
474
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 判断 Schedule 是否属于当前 Pi 会话.
|
|
479
|
+
*
|
|
480
|
+
* @param schedule Schedule 记录
|
|
481
|
+
* @param ctx 当前 Pi 会话上下文
|
|
482
|
+
* @returns 是否允许当前会话执行该 Schedule
|
|
483
|
+
*/
|
|
484
|
+
function scheduleBelongsToSession(schedule: ScheduleRecord, ctx: ExtensionContext): boolean {
|
|
485
|
+
if (schedule.sessionOnly !== true) return true;
|
|
486
|
+
const ownerSessionFile = normalizedSessionFile(schedule.ownerSessionFile);
|
|
487
|
+
const currentSessionFile = normalizedSessionFile(ctx.sessionManager.getSessionFile());
|
|
488
|
+
return ownerSessionFile !== undefined && ownerSessionFile === currentSessionFile;
|
|
489
|
+
}
|
|
490
|
+
|
|
453
491
|
export function listScheduledRunSummaries(cwd: string, root?: string): ScheduleRecord[] {
|
|
454
492
|
return new ScheduleStore(scheduledRunStorePath(cwd, undefined, root), root === undefined ? path.resolve(cwd) : undefined).list();
|
|
455
493
|
}
|
|
@@ -560,6 +598,10 @@ export class ScheduledRunManager {
|
|
|
560
598
|
if (params.catchUp !== undefined && params.catchUp !== "none" && params.catchUp !== "latest") return textResult("catchUp must be 'none' or 'latest'.", undefined, undefined, true);
|
|
561
599
|
if (params.missionId !== undefined || params.mission !== undefined || params.missionUpdate !== undefined || params.missionStatus !== undefined || params.missionScope !== undefined) return textResult("Mission attachment is deferred from this first schedule slice.", undefined, undefined, true);
|
|
562
600
|
if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year") return textResult("Calendar schedules are deferred from this first safe slice. Use a fixed interval such as every:'24h' or every:'7d'.", undefined, undefined, true);
|
|
601
|
+
const sessionOnly = params.sessionOnly === true;
|
|
602
|
+
if (sessionOnly && params.cwd !== undefined && !samePath(params.cwd, ctx.cwd)) return textResult("sessionOnly schedules cannot use an explicit cross-project cwd.", undefined, undefined, true);
|
|
603
|
+
const ownerSessionFile = sessionOnly ? ctx.sessionManager.getSessionFile() : undefined;
|
|
604
|
+
if (sessionOnly && !ownerSessionFile) return textResult("sessionOnly schedules require a persisted current session.", undefined, undefined, true);
|
|
563
605
|
const sessionId = ctx.sessionManager.getSessionId() ?? "unknown";
|
|
564
606
|
if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
|
|
565
607
|
const pendingCount = store.list().filter(hasPendingScheduleWork).length;
|
|
@@ -587,24 +629,25 @@ export class ScheduledRunManager {
|
|
|
587
629
|
catchUp: params.catchUp ?? "latest",
|
|
588
630
|
...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
|
|
589
631
|
paused: false,
|
|
632
|
+
...(sessionOnly ? { sessionOnly: true, ownerSessionFile: path.resolve(ownerSessionFile!) } : {}),
|
|
590
633
|
createdAt: timestamp(now),
|
|
591
634
|
updatedAt: timestamp(now),
|
|
592
635
|
};
|
|
593
636
|
store.write(schedule);
|
|
594
637
|
store.appendEvent(schedule, "schedule.created");
|
|
595
638
|
this.arm(schedule, store);
|
|
596
|
-
return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
|
|
639
|
+
return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nSession only: ${schedule.sessionOnly === true ? "yes" : "no"}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
|
|
597
640
|
}
|
|
598
641
|
|
|
599
642
|
private list(): AgentToolResult<Details> {
|
|
600
643
|
const schedules = this.requireStore().list().sort((a, b) => (a.trigger.nextRunAt ?? "").localeCompare(b.trigger.nextRunAt ?? ""));
|
|
601
644
|
if (!schedules.length) return textResult("No project schedules.", []);
|
|
602
|
-
return textResult([`Project schedules: ${schedules.length}`, ...schedules.map((item) => `- ${item.id} | ${item.paused ? "paused" : item.activeRunId ? "running" : "scheduled"} | ${item.trigger.nextRunAt ?? "no next run"} | ${item.name}`)].join("\n"), schedules);
|
|
645
|
+
return textResult([`Project schedules: ${schedules.length}`, ...schedules.map((item) => `- ${item.id} | ${item.paused ? "paused" : item.activeRunId ? "running" : "scheduled"} | ${item.trigger.nextRunAt ?? "no next run"} | ${item.sessionOnly === true ? "session-only" : "project"} | ${item.name}`)].join("\n"), schedules);
|
|
603
646
|
}
|
|
604
647
|
|
|
605
648
|
private show(params: SubagentParamsLike): AgentToolResult<Details> {
|
|
606
649
|
const schedule = this.resolve(params);
|
|
607
|
-
return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
|
|
650
|
+
return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Session only: ${schedule.sessionOnly === true ? "yes" : "no"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
|
|
608
651
|
}
|
|
609
652
|
|
|
610
653
|
private history(params: SubagentParamsLike): AgentToolResult<Details> {
|
|
@@ -628,13 +671,18 @@ export class ScheduledRunManager {
|
|
|
628
671
|
private async runManual(params: SubagentParamsLike): Promise<AgentToolResult<Details>> {
|
|
629
672
|
const store = this.requireStore();
|
|
630
673
|
const schedule = this.resolve(params);
|
|
674
|
+
const context = this.requireContext(store);
|
|
675
|
+
if (!scheduleBelongsToSession(schedule, context)) {
|
|
676
|
+
return textResult(`Skipped schedule ${schedule.id}: current session is not its owner.`, [schedule]);
|
|
677
|
+
}
|
|
631
678
|
const run = await this.launch(store, schedule, this.now(), "manual", false);
|
|
632
679
|
return textResult(`Manual schedule run ${run.id}: ${run.state}${run.asyncId ? ` (async ${run.asyncId})` : ""}.`, [store.get(schedule.id)], [run], run.state === "failed_launch");
|
|
633
680
|
}
|
|
634
681
|
|
|
635
682
|
private async runDue(): Promise<AgentToolResult<Details>> {
|
|
636
683
|
const store = this.requireStore();
|
|
637
|
-
const
|
|
684
|
+
const context = this.requireContext(store);
|
|
685
|
+
const due = store.list().filter((schedule) => scheduleBelongsToSession(schedule, context) && !schedule.paused && nextRunAt(schedule) !== undefined && nextRunAt(schedule)! <= this.now());
|
|
638
686
|
const runs: ScheduleRunRecord[] = [];
|
|
639
687
|
for (const schedule of due) {
|
|
640
688
|
const planned = duePlannedAt(schedule, this.now())!;
|
|
@@ -659,6 +707,7 @@ export class ScheduledRunManager {
|
|
|
659
707
|
}
|
|
660
708
|
|
|
661
709
|
private restoreOne(store: ScheduleStore, schedule: ScheduleRecord, notBefore?: number, rearm = true): void {
|
|
710
|
+
if (!scheduleBelongsToSession(schedule, this.requireContext(store))) return;
|
|
662
711
|
if (schedule.activeRunId) {
|
|
663
712
|
const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
|
|
664
713
|
if (run?.state === "running" && run.asyncId) this.observedAsyncIds.add(run.asyncId);
|
|
@@ -737,6 +786,7 @@ export class ScheduledRunManager {
|
|
|
737
786
|
// is then nothing to run and nothing to re-arm.
|
|
738
787
|
const schedule = store.find(id);
|
|
739
788
|
if (!schedule) return;
|
|
789
|
+
if (!scheduleBelongsToSession(schedule, this.requireContext(store))) return;
|
|
740
790
|
const planned = duePlannedAt(schedule, this.now());
|
|
741
791
|
if (planned === undefined || schedule.paused) return;
|
|
742
792
|
if (planned > this.now()) return this.arm(schedule, store);
|
|
@@ -857,13 +907,20 @@ export class ScheduledRunManager {
|
|
|
857
907
|
private selectProject(cwd: string, ctx: ExtensionContext): void {
|
|
858
908
|
const projectCwd = path.resolve(cwd);
|
|
859
909
|
const root = scheduledRunStorePath(projectCwd, undefined, this.deps.storeRoot);
|
|
860
|
-
|
|
910
|
+
const isBoundContext = path.resolve(ctx.cwd) === projectCwd;
|
|
911
|
+
const previousContext = this.contexts.get(root);
|
|
912
|
+
const contextChanged = isBoundContext
|
|
913
|
+
&& previousContext !== undefined
|
|
914
|
+
&& normalizedSessionFile(previousContext.sessionManager.getSessionFile()) !== normalizedSessionFile(ctx.sessionManager.getSessionFile());
|
|
915
|
+
if (isBoundContext) this.contexts.set(root, snapshotContext(ctx, projectCwd));
|
|
861
916
|
else if (!this.contexts.has(root)) throw new Error(`Cannot use project '${projectCwd}' until that project has been opened in this runtime.`);
|
|
862
917
|
let store = this.stores.get(root);
|
|
863
918
|
if (!store) {
|
|
864
919
|
store = new ScheduleStore(root, this.deps.storeRoot === undefined ? projectCwd : undefined);
|
|
865
920
|
this.stores.set(root, store);
|
|
866
921
|
this.restore(store);
|
|
922
|
+
} else if (contextChanged) {
|
|
923
|
+
this.restore(store);
|
|
867
924
|
}
|
|
868
925
|
this.store = store;
|
|
869
926
|
}
|
|
@@ -23,7 +23,10 @@ export function steeringMessagePreview(message: string): string {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function steeringReceipt(message: string, receipt: string): string {
|
|
26
|
-
|
|
26
|
+
const preview = steeringMessagePreview(message);
|
|
27
|
+
const longestFence = Math.max(2, ...[...preview.matchAll(/`{3,}/g)].map((match) => match[0]!.length));
|
|
28
|
+
const fence = "`".repeat(longestFence + 1);
|
|
29
|
+
return `${receipt}\n\nMessage sent:\n${fence}text\n${preview}\n${fence}`;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function createSteeringStatus(): SteeringStatus {
|