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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
+ }
@@ -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>,