pi-better-goal 0.11.0 → 0.12.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/package.json +1 -1
- package/src/index.ts +50 -1
- package/src/operation-admission.ts +16 -0
- package/src/workflow-gates.ts +15 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Type } from "typebox";
|
|
|
6
6
|
import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
|
|
7
7
|
import { goalPreferencesPath, readGoalPreferences, writeGoalPreference, type GoalPreferences } from "./preferences.js";
|
|
8
8
|
import { createGoalSettingsPage } from "./settings-page.js";
|
|
9
|
+
import { OPERATION_RESUME_ADMISSION, operationOwnerMatches, type OperationResumeRequest } from "./operation-admission.js";
|
|
9
10
|
|
|
10
11
|
import {
|
|
11
12
|
collectActivitySnapshot,
|
|
@@ -41,7 +42,7 @@ import { createRenderScheduler } from "./shared-render-scheduler.js";
|
|
|
41
42
|
import { initializeNavigatorPanels, setNavigatorPanel } from "./shared-navigator-panels.js";
|
|
42
43
|
import { collectSubagentActivity } from "./subagents.js";
|
|
43
44
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
44
|
-
import { completionGate, pauseGate, schedulerWaitUntil } from "./workflow-gates.js";
|
|
45
|
+
import { completionGate, pauseGate, schedulerWaitUntil, operationResumeSchedulerGate } from "./workflow-gates.js";
|
|
45
46
|
import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
|
|
46
47
|
import { requestTaskContext } from "./task-context.js";
|
|
47
48
|
import {
|
|
@@ -313,6 +314,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
313
314
|
|
|
314
315
|
let currentCtx: ExtensionContext | undefined;
|
|
315
316
|
let executionGeneration = 0;
|
|
317
|
+
let operationAdmissionRevision = 0;
|
|
316
318
|
let agentGeneration = -1;
|
|
317
319
|
let turnGeneration = 0;
|
|
318
320
|
let escapeAbortSuppression: { session: number; turn: number; execution: number } | undefined;
|
|
@@ -862,6 +864,51 @@ export default function (pi: ExtensionAPI): void {
|
|
|
862
864
|
);
|
|
863
865
|
};
|
|
864
866
|
|
|
867
|
+
const unsubscribeOperationAdmission = pi.events.on(OPERATION_RESUME_ADMISSION, (value) => {
|
|
868
|
+
const request = value as Partial<OperationResumeRequest> | undefined;
|
|
869
|
+
if (request?.version !== 1 || !request.owner || typeof request.reply !== "function") return;
|
|
870
|
+
const ctx = currentCtx;
|
|
871
|
+
const revision = executionGeneration;
|
|
872
|
+
const preferencesRevision = operationAdmissionRevision;
|
|
873
|
+
const reply = request.reply;
|
|
874
|
+
const held = (reason: string) => reply({ allowed: false, reason, revision: executionGeneration });
|
|
875
|
+
if (!operationOwnerMatches(ctx, request.owner) || !ctx) { held("Owning parent session or branch is inactive"); return; }
|
|
876
|
+
const goal = getGoal(ctx);
|
|
877
|
+
if ((request.owner.goalId ?? null) !== (goal?.goalId ?? null)) { held("Owning Goal is no longer current"); return; }
|
|
878
|
+
if (goal && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
|
|
879
|
+
if (goal && !isPokeable(goal)) { held("Goal is not active"); return; }
|
|
880
|
+
if (goal && currentPermissionHold(ctx, goal.goalId).blockers.length) { held("Goal has an active permission hold"); return; }
|
|
881
|
+
if (goal && currentContinuationState(ctx, goal.goalId)?.blocked) { held("Goal continuation requires human resume"); return; }
|
|
882
|
+
if (goal && !boundCommandReady(goal, ctx)) { held("Bound workflow is not ready"); return; }
|
|
883
|
+
const workflow = getWorkflow(ctx);
|
|
884
|
+
void (workflow ? operationResumeSchedulerGate(ctx.sessionManager.getBranch(), workflow) : Promise.resolve({ ok: true as const, waitUntil: undefined })).then((gate) => {
|
|
885
|
+
if (ctx !== currentCtx || revision !== executionGeneration || preferencesRevision !== operationAdmissionRevision || !operationOwnerMatches(currentCtx, request.owner!)) {
|
|
886
|
+
held("Parent execution changed during resume admission"); return;
|
|
887
|
+
}
|
|
888
|
+
const latest = getGoal(ctx);
|
|
889
|
+
if ((request.owner!.goalId ?? null) !== (latest?.goalId ?? null)) { held("Owning Goal changed during resume admission"); return; }
|
|
890
|
+
if (latest && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
|
|
891
|
+
if (latest && (!isPokeable(latest) || currentPermissionHold(ctx, latest.goalId).blockers.length || currentContinuationState(ctx, latest.goalId)?.blocked)) {
|
|
892
|
+
held("Goal became held during resume admission"); return;
|
|
893
|
+
}
|
|
894
|
+
if (!gate.ok) { held(gate.reason); return; }
|
|
895
|
+
if (gate.waitUntil && gate.waitUntil > Date.now()) { held("Workflow scheduler is waiting"); return; }
|
|
896
|
+
// An in-process capability lets the dispatcher validate this admission
|
|
897
|
+
// after its last await and immediately before releasing execution.
|
|
898
|
+
const isCurrent = (): boolean => {
|
|
899
|
+
if (ctx !== currentCtx || revision !== executionGeneration || preferencesRevision !== operationAdmissionRevision
|
|
900
|
+
|| !operationOwnerMatches(currentCtx, request.owner!)) return false;
|
|
901
|
+
const active = getGoal(ctx);
|
|
902
|
+
if ((request.owner!.goalId ?? null) !== (active?.goalId ?? null)) return false;
|
|
903
|
+
if (JSON.stringify(getWorkflow(ctx)) !== JSON.stringify(workflow)) return false;
|
|
904
|
+
return !active || (preferences.autoContinue && isPokeable(active)
|
|
905
|
+
&& !currentPermissionHold(ctx, active.goalId).blockers.length
|
|
906
|
+
&& !currentContinuationState(ctx, active.goalId)?.blocked && boundCommandReady(active, ctx));
|
|
907
|
+
};
|
|
908
|
+
reply({ allowed: true, revision, isCurrent });
|
|
909
|
+
}, () => held("Workflow admission evidence is unavailable"));
|
|
910
|
+
});
|
|
911
|
+
|
|
865
912
|
pi.events.on(EVENT_REGISTER_PROVIDER, (provider) => {
|
|
866
913
|
const candidate = provider as Partial<BackgroundActivityProvider> | undefined;
|
|
867
914
|
if (!candidate || typeof candidate.id !== "string" || typeof candidate.getActivity !== "function") {
|
|
@@ -880,6 +927,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
880
927
|
const changePreference = async (ctx: ExtensionCommandContext, key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
|
|
881
928
|
const previous = preferences;
|
|
882
929
|
preferences = writeGoalPreference(key, enabled);
|
|
930
|
+
operationAdmissionRevision += 1;
|
|
883
931
|
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
884
932
|
syncResumeTool(getGoal(ctx));
|
|
885
933
|
if (!previous.autoContinue && preferences.autoContinue) {
|
|
@@ -1576,6 +1624,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1576
1624
|
|
|
1577
1625
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1578
1626
|
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
1627
|
+
if (typeof unsubscribeOperationAdmission === "function") unsubscribeOperationAdmission();
|
|
1579
1628
|
|
|
1580
1629
|
executionGeneration += 1;
|
|
1581
1630
|
sessionGeneration += 1;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {ExtensionContext} from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export const OPERATION_RESUME_ADMISSION='pi-better-goal:operation-resume-admission';
|
|
4
|
+
export interface OperationResumeOwner {sessionId:string;workspace:string;branchId?:string|null;goalId?:string|null}
|
|
5
|
+
export interface OperationResumeGate {allowed:boolean;reason?:string;revision:number;isCurrent?:()=>boolean}
|
|
6
|
+
export interface OperationResumeRequest {version:1;owner:OperationResumeOwner;reply:(gate:OperationResumeGate)=>void}
|
|
7
|
+
|
|
8
|
+
/** A recorded branch anchor remains valid on descendants, not on an abandoned fork. */
|
|
9
|
+
export function operationOwnerMatches(ctx:ExtensionContext|undefined,owner:OperationResumeOwner):boolean {
|
|
10
|
+
if(!ctx||ctx.cwd!==owner.workspace)return false;
|
|
11
|
+
try{
|
|
12
|
+
if(ctx.sessionManager.getSessionId()!==owner.sessionId)return false;
|
|
13
|
+
if(owner.branchId&&!ctx.sessionManager.getBranch().some(entry=>entry.id===owner.branchId))return false;
|
|
14
|
+
return true;
|
|
15
|
+
}catch{return false;}
|
|
16
|
+
}
|
package/src/workflow-gates.ts
CHANGED
|
@@ -262,6 +262,21 @@ export function waitUntil(due: readonly SchedulerAction[]): number | undefined {
|
|
|
262
262
|
return times.length > 0 ? Math.min(...times) : undefined;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
export type OperationResumeSchedulerGate = { ok: true; waitUntil: number | undefined } | { ok: false; reason: string };
|
|
266
|
+
|
|
267
|
+
/** Automatic worker admission cannot treat unavailable workflow evidence as permission. */
|
|
268
|
+
export async function operationResumeSchedulerGate(
|
|
269
|
+
entries: Iterable<EntryLike>,
|
|
270
|
+
owner: WorkflowOwner,
|
|
271
|
+
options: { timeoutMs?: number } = {},
|
|
272
|
+
): Promise<OperationResumeSchedulerGate> {
|
|
273
|
+
const located = locateRun(entries, owner);
|
|
274
|
+
if ("warning" in located) return { ok: false, reason: located.warning };
|
|
275
|
+
const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
|
|
276
|
+
if (!run.ok) return { ok: false, reason: `Workflow admission evidence unavailable: ${run.reason}` };
|
|
277
|
+
return { ok: true, waitUntil: waitUntil(run.due) };
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
/** When the bound run next wants a continuation, or undefined when it is not only waiting. */
|
|
266
281
|
export async function schedulerWaitUntil(
|
|
267
282
|
entries: Iterable<EntryLike>,
|