@yycholla/pi-dynamic-workflows 3.3.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/LICENSE +22 -0
- package/README.md +325 -0
- package/assets/readme/hero.png +0 -0
- package/assets/readme/hero.svg +70 -0
- package/assets/readme/package-cover.png +0 -0
- package/assets/readme/package-cover.svg +66 -0
- package/assets/readme/workflow.png +0 -0
- package/assets/readme/workflow.svg +77 -0
- package/dist/accept-workflow-guidance.d.ts +14 -0
- package/dist/accept-workflow-guidance.js +53 -0
- package/dist/adversarial-review.d.ts +30 -0
- package/dist/adversarial-review.js +107 -0
- package/dist/agent-history.d.ts +20 -0
- package/dist/agent-history.js +122 -0
- package/dist/agent-registry.d.ts +83 -0
- package/dist/agent-registry.js +190 -0
- package/dist/agent.d.ts +364 -0
- package/dist/agent.js +714 -0
- package/dist/builtin-commands.d.ts +19 -0
- package/dist/builtin-commands.js +251 -0
- package/dist/builtin-workflows.d.ts +45 -0
- package/dist/builtin-workflows.js +121 -0
- package/dist/code-review.d.ts +26 -0
- package/dist/code-review.js +181 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +44 -0
- package/dist/deep-research.d.ts +30 -0
- package/dist/deep-research.js +124 -0
- package/dist/display.d.ts +134 -0
- package/dist/display.js +248 -0
- package/dist/effort-command.d.ts +28 -0
- package/dist/effort-command.js +68 -0
- package/dist/enums.d.ts +69 -0
- package/dist/enums.js +78 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.js +140 -0
- package/dist/extension-reload.d.ts +37 -0
- package/dist/extension-reload.js +78 -0
- package/dist/fs-persistence.d.ts +63 -0
- package/dist/fs-persistence.js +102 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +35 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +66 -0
- package/dist/model-routing.d.ts +30 -0
- package/dist/model-routing.js +50 -0
- package/dist/model-spec.d.ts +29 -0
- package/dist/model-spec.js +252 -0
- package/dist/model-tier-config.d.ts +133 -0
- package/dist/model-tier-config.js +249 -0
- package/dist/run-persistence.d.ts +180 -0
- package/dist/run-persistence.js +294 -0
- package/dist/saved-commands.d.ts +28 -0
- package/dist/saved-commands.js +100 -0
- package/dist/shared-store.d.ts +98 -0
- package/dist/shared-store.js +212 -0
- package/dist/structured-output.d.ts +19 -0
- package/dist/structured-output.js +30 -0
- package/dist/task-panel.d.ts +61 -0
- package/dist/task-panel.js +422 -0
- package/dist/usage-limit-scheduler.d.ts +145 -0
- package/dist/usage-limit-scheduler.js +368 -0
- package/dist/web-tools.d.ts +20 -0
- package/dist/web-tools.js +120 -0
- package/dist/workflow-authoring-coverage.d.ts +70 -0
- package/dist/workflow-authoring-coverage.js +421 -0
- package/dist/workflow-authoring-reference.d.ts +20 -0
- package/dist/workflow-authoring-reference.js +156 -0
- package/dist/workflow-capability-contract.d.ts +131 -0
- package/dist/workflow-capability-contract.js +604 -0
- package/dist/workflow-commands.d.ts +18 -0
- package/dist/workflow-commands.js +260 -0
- package/dist/workflow-comprehension.d.ts +133 -0
- package/dist/workflow-comprehension.js +1321 -0
- package/dist/workflow-context-measurement.d.ts +72 -0
- package/dist/workflow-context-measurement.js +213 -0
- package/dist/workflow-control-tool.d.ts +30 -0
- package/dist/workflow-control-tool.js +176 -0
- package/dist/workflow-delivery-choice.d.ts +20 -0
- package/dist/workflow-delivery-choice.js +48 -0
- package/dist/workflow-editor.d.ts +93 -0
- package/dist/workflow-editor.js +363 -0
- package/dist/workflow-manager.d.ts +492 -0
- package/dist/workflow-manager.js +1124 -0
- package/dist/workflow-paths.d.ts +22 -0
- package/dist/workflow-paths.js +46 -0
- package/dist/workflow-release-gate.d.ts +39 -0
- package/dist/workflow-release-gate.js +309 -0
- package/dist/workflow-saved.d.ts +38 -0
- package/dist/workflow-saved.js +126 -0
- package/dist/workflow-settings.d.ts +70 -0
- package/dist/workflow-settings.js +131 -0
- package/dist/workflow-tool.d.ts +71 -0
- package/dist/workflow-tool.js +367 -0
- package/dist/workflow-ui.d.ts +182 -0
- package/dist/workflow-ui.js +1587 -0
- package/dist/workflow.d.ts +333 -0
- package/dist/workflow.js +1151 -0
- package/dist/workflows-models-command.d.ts +31 -0
- package/dist/workflows-models-command.js +156 -0
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/extensions/workflow.ts +151 -0
- package/package.json +104 -0
- package/skills/workflow-authoring/SKILL.md +30 -0
- package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
- package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
- package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
- package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
- package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
- package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
- package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
- package/skills/workflow-authoring/examples/structured-output.js +37 -0
- package/skills/workflow-authoring/examples/tournament.js +81 -0
- package/skills/workflow-authoring/examples/validated-gate.js +63 -0
- package/skills/workflow-authoring/references/capabilities.md +41 -0
- package/skills/workflow-authoring/references/capability-details.md +357 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +27 -0
- package/skills/workflow-authoring/references/focused-recipes.md +11 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +33 -0
- package/skills/workflow-authoring/references/pattern-selection.md +15 -0
- package/skills/workflow-authoring/references/quality-helpers.md +8 -0
- package/skills/workflow-authoring/references/registry-ownership.md +15 -0
- package/skills/workflow-authoring/references/retry-helper.md +5 -0
- package/skills/workflow-authoring/references/review.md +43 -0
- package/skills/workflow-authoring/references/runtime.md +27 -0
- package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
- package/skills/workflow-authoring/references/versions.md +13 -0
- package/skills/workflow-patterns/SKILL.md +51 -0
- package/src/accept-workflow-guidance.ts +71 -0
- package/src/adversarial-review.ts +120 -0
- package/src/agent-history.ts +157 -0
- package/src/agent-registry.ts +221 -0
- package/src/agent.ts +929 -0
- package/src/builtin-commands.ts +286 -0
- package/src/builtin-workflows.ts +155 -0
- package/src/code-review.ts +183 -0
- package/src/config.ts +55 -0
- package/src/deep-research.ts +135 -0
- package/src/display.ts +367 -0
- package/src/effort-command.ts +87 -0
- package/src/enums.ts +77 -0
- package/src/errors.ts +199 -0
- package/src/extension-reload.ts +100 -0
- package/src/fs-persistence.ts +124 -0
- package/src/index.ts +176 -0
- package/src/logger.ts +88 -0
- package/src/model-routing.ts +73 -0
- package/src/model-spec.ts +309 -0
- package/src/model-tier-config.ts +296 -0
- package/src/run-persistence.ts +484 -0
- package/src/saved-commands.ts +115 -0
- package/src/shared-store.ts +228 -0
- package/src/structured-output.ts +47 -0
- package/src/task-panel.ts +490 -0
- package/src/usage-limit-scheduler.ts +432 -0
- package/src/web-tools.ts +124 -0
- package/src/workflow-authoring-coverage.ts +486 -0
- package/src/workflow-authoring-reference.ts +186 -0
- package/src/workflow-capability-contract.ts +806 -0
- package/src/workflow-commands.ts +287 -0
- package/src/workflow-comprehension.ts +1673 -0
- package/src/workflow-context-measurement.ts +262 -0
- package/src/workflow-control-tool.ts +238 -0
- package/src/workflow-delivery-choice.ts +69 -0
- package/src/workflow-editor.ts +444 -0
- package/src/workflow-manager.ts +1405 -0
- package/src/workflow-paths.ts +63 -0
- package/src/workflow-release-gate.ts +529 -0
- package/src/workflow-saved.ts +180 -0
- package/src/workflow-settings.ts +194 -0
- package/src/workflow-tool.ts +464 -0
- package/src/workflow-ui.ts +1789 -0
- package/src/workflow.ts +1615 -0
- package/src/workflows-models-command.ts +211 -0
- package/src/worktree.ts +76 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-resume for runs paused on a provider usage limit.
|
|
3
|
+
*
|
|
4
|
+
* A workflow run pauses (does not fail) when a provider quota/usage limit is hit
|
|
5
|
+
* (see errors.ts PROVIDER_USAGE_LIMIT, workflow-manager.ts executeRun()'s catch
|
|
6
|
+
* block). Left alone, the run just sits there until a human runs /workflows and
|
|
7
|
+
* hits resume. This module watches the manager's public event stream and, for
|
|
8
|
+
* runs that are auto-resume-eligible, arms a timer to call manager.resume() once
|
|
9
|
+
* the provider's quota is likely to have refilled — with exponential backoff if
|
|
10
|
+
* it keeps hitting the wall, and a hard attempt cap so it never retries forever.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately standalone: it consumes ONLY WorkflowManager's public surface
|
|
13
|
+
* (on/off, listAllRuns, resume, getPersistence) so it stays decoupled from
|
|
14
|
+
* manager/persistence internals. It owns its own timers and its own bookkeeping
|
|
15
|
+
* (in-memory, best-effort persisted) — it does not rely on manager.stop(), which
|
|
16
|
+
* only operates on in-memory runs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PersistedRunState, RunPersistence, RunStatus } from "./run-persistence.js";
|
|
20
|
+
|
|
21
|
+
/** Narrow surface this scheduler depends on — satisfied by WorkflowManager. */
|
|
22
|
+
export interface SchedulableWorkflowManager {
|
|
23
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
24
|
+
off(event: string, listener: (...args: any[]) => void): unknown;
|
|
25
|
+
listAllRuns(): PersistedRunState[];
|
|
26
|
+
resume(runId: string): Promise<boolean>;
|
|
27
|
+
getPersistence(): RunPersistence;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Opaque timer handle so tests can inject a fake clock/timer. */
|
|
31
|
+
export type TimerHandle = unknown;
|
|
32
|
+
|
|
33
|
+
export interface UsageLimitSchedulerOptions {
|
|
34
|
+
/** Injectable clock (default Date.now). */
|
|
35
|
+
now?: () => number;
|
|
36
|
+
/** Injectable timer scheduler (default setTimeout). */
|
|
37
|
+
setTimer?: (fn: () => void, ms: number) => TimerHandle;
|
|
38
|
+
/** Injectable timer canceller (default clearTimeout). */
|
|
39
|
+
clearTimer?: (handle: TimerHandle) => void;
|
|
40
|
+
/** Max auto-resume attempts per pause-cycle before giving up. Default 5. */
|
|
41
|
+
maxAttempts?: number;
|
|
42
|
+
/** Delay floor — never arm sooner than this. Default 60_000 (1m). */
|
|
43
|
+
minDelayMs?: number;
|
|
44
|
+
/** Delay used when the provider's resetHint can't be parsed. Default 300_000 (5m). */
|
|
45
|
+
fallbackDelayMs?: number;
|
|
46
|
+
/** Delay ceiling — backoff is clamped here. Default 6h. */
|
|
47
|
+
maxDelayMs?: number;
|
|
48
|
+
/** Diagnostics sink; defaults to console.warn. Never throws back into the caller. */
|
|
49
|
+
onDiagnostic?: (message: string, detail?: unknown) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RunState {
|
|
53
|
+
/** How many auto-resume attempts have been made (or armed) for this pause-cycle. */
|
|
54
|
+
attempts: number;
|
|
55
|
+
/** The currently armed timer, if any. */
|
|
56
|
+
timer?: TimerHandle;
|
|
57
|
+
/** Set once the attempt cap is hit, so the give-up diagnostic logs once. */
|
|
58
|
+
gaveUp?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
62
|
+
const DEFAULT_MIN_DELAY_MS = 60_000;
|
|
63
|
+
const DEFAULT_FALLBACK_DELAY_MS = 300_000;
|
|
64
|
+
const DEFAULT_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Best-effort parse of a provider's human reset hint ("Resets in ~3h",
|
|
68
|
+
* "resets in 5m", "in 90s", "1h30m") into milliseconds. Sums every
|
|
69
|
+
* (number, unit) pair found, so combined forms like "1h30m" work for free.
|
|
70
|
+
* Returns undefined when nothing recognizable is found — callers should fall
|
|
71
|
+
* back to a fixed delay rather than guess.
|
|
72
|
+
*/
|
|
73
|
+
export function parseResetHintMs(hint?: string): number | undefined {
|
|
74
|
+
if (!hint) return undefined;
|
|
75
|
+
// No trailing \b: combined forms like "1h30m" have a digit right after the
|
|
76
|
+
// unit letter, which is itself a word character, so \b would never match
|
|
77
|
+
// there. A negative lookahead for another letter is the correct boundary —
|
|
78
|
+
// it still stops "hours" from partially matching as bare "h" mid-word while
|
|
79
|
+
// allowing a unit to be followed immediately by the next (digit, unit) pair.
|
|
80
|
+
const re = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])/gi;
|
|
81
|
+
let match: RegExpExecArray | null;
|
|
82
|
+
let totalMs = 0;
|
|
83
|
+
let found = false;
|
|
84
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
85
|
+
while ((match = re.exec(hint)) !== null) {
|
|
86
|
+
const value = Number.parseFloat(match[1]);
|
|
87
|
+
if (!Number.isFinite(value)) continue;
|
|
88
|
+
const unit = match[2].toLowerCase();
|
|
89
|
+
found = true;
|
|
90
|
+
if (unit.startsWith("h")) totalMs += value * 3_600_000;
|
|
91
|
+
else if (unit.startsWith("m")) totalMs += value * 60_000;
|
|
92
|
+
else if (unit.startsWith("s")) totalMs += value * 1_000;
|
|
93
|
+
}
|
|
94
|
+
return found ? totalMs : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface AutoResumeDelayParams {
|
|
98
|
+
/** The provider's verbatim reset hint for this pause, if any. */
|
|
99
|
+
resetHint?: string;
|
|
100
|
+
/** 1-indexed attempt number for the pause currently being armed. */
|
|
101
|
+
attempts: number;
|
|
102
|
+
/** Milliseconds already elapsed since the pause began (0 for a live pause). */
|
|
103
|
+
elapsedMs: number;
|
|
104
|
+
minDelayMs: number;
|
|
105
|
+
fallbackDelayMs: number;
|
|
106
|
+
maxDelayMs: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* delay = clamp(minDelayMs, remaining * 2^(attempts-1), maxDelayMs), where
|
|
111
|
+
* remaining = parsed(resetHint) ?? fallbackDelayMs, minus time already elapsed.
|
|
112
|
+
* The exponent is capped defensively so a pathological attempt count can't
|
|
113
|
+
* overflow the multiplication to Infinity/NaN before the maxDelayMs clamp runs.
|
|
114
|
+
*/
|
|
115
|
+
export function computeAutoResumeDelayMs(params: AutoResumeDelayParams): number {
|
|
116
|
+
const base = parseResetHintMs(params.resetHint) ?? params.fallbackDelayMs;
|
|
117
|
+
const remaining = base - params.elapsedMs;
|
|
118
|
+
const exponent = Math.min(Math.max(params.attempts - 1, 0), 30);
|
|
119
|
+
const backoff = remaining * 2 ** exponent;
|
|
120
|
+
return Math.min(params.maxDelayMs, Math.max(params.minDelayMs, backoff));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Watches a WorkflowManager for usage-limit pauses and auto-resumes eligible
|
|
125
|
+
* runs once the provider's quota is likely to have refilled.
|
|
126
|
+
*
|
|
127
|
+
* Event-driven "fire and watch": an attempt is consumed when a run ENTERS a
|
|
128
|
+
* usage_limit pause (live via the "paused" event, or once at cold start for a
|
|
129
|
+
* run that was already paused), never when a resume is merely fired. When an
|
|
130
|
+
* armed timer fires, resume() is called; if it returns false (lease busy, run
|
|
131
|
+
* already gone, etc.) no attempt is consumed and a short un-backed-off retry is
|
|
132
|
+
* armed instead, unless the run has reached a terminal state on disk. If resume()
|
|
133
|
+
* returns true, this scheduler steps back — the existing "paused" subscription
|
|
134
|
+
* re-arms with backoff if the run hits the wall again, and "complete"/"error"/
|
|
135
|
+
* "stopped" clean up its timer.
|
|
136
|
+
*/
|
|
137
|
+
export class UsageLimitScheduler {
|
|
138
|
+
private readonly manager: SchedulableWorkflowManager;
|
|
139
|
+
private readonly now: () => number;
|
|
140
|
+
private readonly setTimer: (fn: () => void, ms: number) => TimerHandle;
|
|
141
|
+
private readonly clearTimer: (handle: TimerHandle) => void;
|
|
142
|
+
private readonly maxAttempts: number;
|
|
143
|
+
private readonly minDelayMs: number;
|
|
144
|
+
private readonly fallbackDelayMs: number;
|
|
145
|
+
private readonly maxDelayMs: number;
|
|
146
|
+
private readonly diagnostic: (message: string, detail?: unknown) => void;
|
|
147
|
+
|
|
148
|
+
private readonly state = new Map<string, RunState>();
|
|
149
|
+
private disposed = false;
|
|
150
|
+
/**
|
|
151
|
+
* Runs this scheduler is currently auto-resuming (its own timer fired). Used to
|
|
152
|
+
* tell an auto-resume's "resumed" event apart from a manual one: an auto-resume
|
|
153
|
+
* must keep the backoff counter (it IS the backoff), a manual resume resets it.
|
|
154
|
+
*/
|
|
155
|
+
private readonly autoResumingRunIds = new Set<string>();
|
|
156
|
+
|
|
157
|
+
private readonly onPaused = (event: { runId?: string; reason?: string; resetHint?: string }): void => {
|
|
158
|
+
this.safe(() => this.handlePaused(event));
|
|
159
|
+
};
|
|
160
|
+
private readonly onTerminal = (event: { runId?: string }): void => {
|
|
161
|
+
this.safe(() => this.cleanup(event?.runId));
|
|
162
|
+
};
|
|
163
|
+
private readonly onResumed = (event: { runId?: string }): void => {
|
|
164
|
+
this.safe(() => this.handleResumed(event));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
constructor(manager: SchedulableWorkflowManager, options: UsageLimitSchedulerOptions = {}) {
|
|
168
|
+
this.manager = manager;
|
|
169
|
+
this.now = options.now ?? Date.now;
|
|
170
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
171
|
+
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as NodeJS.Timeout));
|
|
172
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
173
|
+
this.minDelayMs = options.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
|
|
174
|
+
this.fallbackDelayMs = options.fallbackDelayMs ?? DEFAULT_FALLBACK_DELAY_MS;
|
|
175
|
+
this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
176
|
+
this.diagnostic =
|
|
177
|
+
options.onDiagnostic ??
|
|
178
|
+
((message, detail) => {
|
|
179
|
+
console.warn(message, detail ?? "");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
this.manager.on("paused", this.onPaused);
|
|
183
|
+
this.manager.on("resumed", this.onResumed);
|
|
184
|
+
this.manager.on("complete", this.onTerminal);
|
|
185
|
+
this.manager.on("error", this.onTerminal);
|
|
186
|
+
this.manager.on("stopped", this.onTerminal);
|
|
187
|
+
|
|
188
|
+
// Cold-start re-arm: pick up any run that was already paused-on-usage_limit
|
|
189
|
+
// before this process (and thus this scheduler instance) existed.
|
|
190
|
+
this.safe(() => this.coldStartRearm());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Clear every armed timer and unsubscribe from the manager. Idempotent. */
|
|
194
|
+
dispose(): void {
|
|
195
|
+
if (this.disposed) return;
|
|
196
|
+
this.disposed = true;
|
|
197
|
+
this.manager.off("paused", this.onPaused);
|
|
198
|
+
this.manager.off("resumed", this.onResumed);
|
|
199
|
+
this.manager.off("complete", this.onTerminal);
|
|
200
|
+
this.manager.off("error", this.onTerminal);
|
|
201
|
+
this.manager.off("stopped", this.onTerminal);
|
|
202
|
+
for (const entry of this.state.values()) {
|
|
203
|
+
if (entry.timer !== undefined) this.clearTimer(entry.timer);
|
|
204
|
+
}
|
|
205
|
+
this.state.clear();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Test/diagnostic helper: in-memory attempt count tracked for a run, if any. */
|
|
209
|
+
getAttemptCount(runId: string): number | undefined {
|
|
210
|
+
return this.state.get(runId)?.attempts;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Test/diagnostic helper: whether a resume timer is currently armed for a run. */
|
|
214
|
+
hasArmedTimer(runId: string): boolean {
|
|
215
|
+
return this.state.get(runId)?.timer !== undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- event handlers -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
private handlePaused(event: { runId?: string; reason?: string; resetHint?: string }): void {
|
|
221
|
+
if (this.disposed || !event?.runId || event.reason !== "usage_limit") return;
|
|
222
|
+
const runId = event.runId;
|
|
223
|
+
|
|
224
|
+
// The "paused" event fires BEFORE the manager's own persistRun() write for
|
|
225
|
+
// this pause (see executeRun()'s catch block: emit then persist). A disk
|
|
226
|
+
// read here can therefore be stale for fields this exact pause is about to
|
|
227
|
+
// set (status/pauseReason/resetHint) — but NOT for `autoResume`, which is
|
|
228
|
+
// fixed at run-start and persisted on every persistRun() call since, so a
|
|
229
|
+
// stale read of it is still correct. resetHint comes off the event itself,
|
|
230
|
+
// not disk, to avoid that race.
|
|
231
|
+
const persisted = this.safeLoad(runId);
|
|
232
|
+
if (persisted?.autoResume === false) {
|
|
233
|
+
this.diagnostic(`[usage-limit-scheduler] ${runId}: autoResume is disabled for this run, not arming`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const priorAttempts = this.state.get(runId)?.attempts ?? persisted?.autoResumeAttempts ?? 0;
|
|
238
|
+
this.arm(runId, {
|
|
239
|
+
attempts: priorAttempts + 1,
|
|
240
|
+
resetHint: event.resetHint ?? persisted?.resetHint,
|
|
241
|
+
elapsedMs: 0,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private cleanup(runId?: string): void {
|
|
246
|
+
if (!runId) return;
|
|
247
|
+
const entry = this.state.get(runId);
|
|
248
|
+
if (entry?.timer !== undefined) this.clearTimer(entry.timer);
|
|
249
|
+
this.state.delete(runId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A run was resumed. If WE resumed it (auto-resume timer fired), leave the
|
|
254
|
+
* backoff counter alone — that's the sequence doing its job, and it must still
|
|
255
|
+
* be able to reach the cap. If a human resumed it (via /workflows), treat that
|
|
256
|
+
* as a deliberate fresh start: drop the in-memory given-up state and reset the
|
|
257
|
+
* persisted counter so a later pause re-enters the normal backoff from attempt 1
|
|
258
|
+
* instead of staying silently given-up forever.
|
|
259
|
+
*/
|
|
260
|
+
private handleResumed(event: { runId?: string }): void {
|
|
261
|
+
if (this.disposed || !event?.runId) return;
|
|
262
|
+
if (this.autoResumingRunIds.has(event.runId)) return;
|
|
263
|
+
this.cleanup(event.runId);
|
|
264
|
+
this.persistAttempts(event.runId, 0);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private coldStartRearm(): void {
|
|
268
|
+
const runs = this.manager.listAllRuns();
|
|
269
|
+
for (const run of runs) {
|
|
270
|
+
if (run.status !== "paused" || run.pauseReason !== "usage_limit") continue;
|
|
271
|
+
if (run.autoResume === false) continue;
|
|
272
|
+
if (this.state.has(run.runId)) continue;
|
|
273
|
+
|
|
274
|
+
const priorAttempts = run.autoResumeAttempts ?? 0;
|
|
275
|
+
const updatedAtMs = Date.parse(run.updatedAt);
|
|
276
|
+
const elapsedMs = Number.isFinite(updatedAtMs) ? Math.max(0, this.now() - updatedAtMs) : 0;
|
|
277
|
+
this.arm(run.runId, {
|
|
278
|
+
attempts: priorAttempts + 1,
|
|
279
|
+
resetHint: run.resetHint,
|
|
280
|
+
elapsedMs,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- arming / firing ------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
private arm(runId: string, params: { attempts: number; resetHint?: string; elapsedMs: number }): void {
|
|
288
|
+
const existing = this.state.get(runId);
|
|
289
|
+
if (existing?.timer !== undefined) this.clearTimer(existing.timer);
|
|
290
|
+
|
|
291
|
+
if (params.attempts > this.maxAttempts) {
|
|
292
|
+
const alreadyLogged = existing?.gaveUp === true;
|
|
293
|
+
// Freeze the counter at a single sentinel (maxAttempts + 1) instead of
|
|
294
|
+
// storing the raw overflow. coldStartRearm() reads the persisted count and
|
|
295
|
+
// adds 1 on every restart; without this clamp a given-up run's counter
|
|
296
|
+
// grew without bound (…6, 7, 8… → "giving up after 23") across cold starts
|
|
297
|
+
// (#106). Clamping makes the persisted value idempotent — a rearm of an
|
|
298
|
+
// already-given-up run rewrites the same 6.
|
|
299
|
+
const frozen = this.maxAttempts + 1;
|
|
300
|
+
this.state.set(runId, { attempts: frozen, gaveUp: true });
|
|
301
|
+
this.persistAttempts(runId, frozen);
|
|
302
|
+
// Log the give-up exactly once per crossing. In-process the gaveUp flag
|
|
303
|
+
// guards it; across restarts a fresh scheduler has no memory, so also
|
|
304
|
+
// suppress when this arm is merely re-giving-up an already-capped run
|
|
305
|
+
// (params.attempts already past the sentinel, i.e. prior was ≥ frozen).
|
|
306
|
+
if (!alreadyLogged && params.attempts <= frozen) {
|
|
307
|
+
this.diagnostic(
|
|
308
|
+
`[usage-limit-scheduler] ${runId}: giving up after ${this.maxAttempts} auto-resume attempt(s) ` +
|
|
309
|
+
`(max ${this.maxAttempts}); leaving paused for manual resume`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const delay = computeAutoResumeDelayMs({
|
|
316
|
+
resetHint: params.resetHint,
|
|
317
|
+
attempts: params.attempts,
|
|
318
|
+
elapsedMs: params.elapsedMs,
|
|
319
|
+
minDelayMs: this.minDelayMs,
|
|
320
|
+
fallbackDelayMs: this.fallbackDelayMs,
|
|
321
|
+
maxDelayMs: this.maxDelayMs,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const timer = this.setTimer(() => this.safe(() => this.onTimerFire(runId)), delay);
|
|
325
|
+
this.state.set(runId, { attempts: params.attempts, timer });
|
|
326
|
+
this.persistAttempts(runId, params.attempts);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async onTimerFire(runId: string): Promise<void> {
|
|
330
|
+
if (this.disposed) return;
|
|
331
|
+
const entry = this.state.get(runId);
|
|
332
|
+
if (!entry || entry.gaveUp) return;
|
|
333
|
+
// The timer that just fired is spent; clear its handle while we await.
|
|
334
|
+
this.state.set(runId, { ...entry, timer: undefined });
|
|
335
|
+
|
|
336
|
+
let resumed = false;
|
|
337
|
+
// Mark this as OUR resume so handleResumed() (fired synchronously inside
|
|
338
|
+
// resume(), before it returns) doesn't mistake it for a manual resume and
|
|
339
|
+
// reset the backoff counter mid-sequence.
|
|
340
|
+
this.autoResumingRunIds.add(runId);
|
|
341
|
+
try {
|
|
342
|
+
resumed = await this.manager.resume(runId);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
this.diagnostic(`[usage-limit-scheduler] ${runId}: resume() threw`, err);
|
|
345
|
+
resumed = false;
|
|
346
|
+
} finally {
|
|
347
|
+
this.autoResumingRunIds.delete(runId);
|
|
348
|
+
}
|
|
349
|
+
if (this.disposed) return;
|
|
350
|
+
|
|
351
|
+
if (resumed) {
|
|
352
|
+
// Don't consume/advance anything further here — the existing "paused"
|
|
353
|
+
// subscription re-arms (with backoff) if this run hits the wall again,
|
|
354
|
+
// and "complete"/"error"/"stopped" clean up on any terminal outcome.
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// resume() returned false without throwing: it refused for a structural
|
|
359
|
+
// reason (already running/aborted, no persisted script, or the lease is
|
|
360
|
+
// held elsewhere) rather than a real failed attempt. Per the fix for bug
|
|
361
|
+
// (a), that must NOT consume an attempt. Distinguish "gone for good" from
|
|
362
|
+
// "try again shortly":
|
|
363
|
+
const status = this.safeStatus(runId);
|
|
364
|
+
if (status === undefined || status === "completed" || status === "aborted") {
|
|
365
|
+
this.cleanup(runId);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const current = this.state.get(runId) ?? entry;
|
|
370
|
+
const timer = this.setTimer(() => this.safe(() => this.onTimerFire(runId)), this.minDelayMs);
|
|
371
|
+
this.state.set(runId, { attempts: current.attempts, timer });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- helpers --------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
private safeLoad(runId: string): PersistedRunState | undefined {
|
|
377
|
+
try {
|
|
378
|
+
return this.manager.getPersistence().load(runId) ?? undefined;
|
|
379
|
+
} catch (err) {
|
|
380
|
+
this.diagnostic(`[usage-limit-scheduler] ${runId}: persistence load failed`, err);
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private safeStatus(runId: string): RunStatus | undefined {
|
|
386
|
+
try {
|
|
387
|
+
return this.manager.listAllRuns().find((r) => r.runId === runId)?.status;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
this.diagnostic(`[usage-limit-scheduler] ${runId}: listAllRuns() failed`, err);
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Best-effort persist of the in-memory attempt counter, so a cold start after
|
|
396
|
+
* a crash can approximately resume the backoff sequence instead of restarting
|
|
397
|
+
* it. Deferred to a microtask so it lands AFTER the manager's own persistRun()
|
|
398
|
+
* write for this same pause (which happens synchronously, right after the
|
|
399
|
+
* "paused" event we're reacting to returns control to executeRun()) — writing
|
|
400
|
+
* synchronously here would just get clobbered, since persistRun() writes a
|
|
401
|
+
* fresh PersistedRunState object literal that doesn't know about this field.
|
|
402
|
+
* This is still inherently racy across process crashes (see class docs); it
|
|
403
|
+
* is a best-effort durability aid, not a correctness requirement for the live
|
|
404
|
+
* (in-memory) path.
|
|
405
|
+
*/
|
|
406
|
+
private persistAttempts(runId: string, attempts: number): void {
|
|
407
|
+
queueMicrotask(() => {
|
|
408
|
+
if (this.disposed) return;
|
|
409
|
+
try {
|
|
410
|
+
const persistence = this.manager.getPersistence();
|
|
411
|
+
const current = persistence.load(runId);
|
|
412
|
+
if (!current) return;
|
|
413
|
+
persistence.save({ ...current, autoResumeAttempts: attempts });
|
|
414
|
+
} catch (err) {
|
|
415
|
+
this.diagnostic(`[usage-limit-scheduler] ${runId}: failed to persist autoResumeAttempts`, err);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private safe(fn: () => void | Promise<void>): void {
|
|
421
|
+
try {
|
|
422
|
+
const result = fn();
|
|
423
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
424
|
+
(result as Promise<void>).catch((err: unknown) => {
|
|
425
|
+
this.diagnostic("[usage-limit-scheduler] async handler error", err);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
this.diagnostic("[usage-limit-scheduler] handler error", err);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
package/src/web-tools.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real web tools for research workflows. These execute in the extension host
|
|
3
|
+
* process (which has network access), not in a subagent sandbox, so they perform
|
|
4
|
+
* genuine HTTP requests via Node's fetch.
|
|
5
|
+
*
|
|
6
|
+
* - web_search: best-effort Bing HTML scrape -> result {url, title}
|
|
7
|
+
* - web_fetch: fetch a URL and return readable text (HTML stripped, truncated)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
const UA =
|
|
14
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
|
|
15
|
+
|
|
16
|
+
async function fetchText(url: string, timeoutMs = 15000): Promise<{ status: number; body: string }> {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, { headers: { "user-agent": UA }, signal: controller.signal, redirect: "follow" });
|
|
21
|
+
return { status: res.status, body: await res.text() };
|
|
22
|
+
} finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function htmlToText(html: string): string {
|
|
28
|
+
return html
|
|
29
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
30
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
31
|
+
.replace(/<\/(p|div|li|h[1-6]|tr|br)>/gi, "\n")
|
|
32
|
+
.replace(/<[^>]+>/g, " ")
|
|
33
|
+
.replace(/ /g, " ")
|
|
34
|
+
.replace(/&/g, "&")
|
|
35
|
+
.replace(/</g, "<")
|
|
36
|
+
.replace(/>/g, ">")
|
|
37
|
+
.replace(/'|'/g, "'")
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/[ \t]+/g, " ")
|
|
40
|
+
.replace(/\n +/g, "\n")
|
|
41
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseBingResults(html: string, limit: number): Array<{ url: string; title: string }> {
|
|
46
|
+
const out: Array<{ url: string; title: string }> = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const m of html.matchAll(/<h2[^>]*>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
|
|
49
|
+
const url = m[1];
|
|
50
|
+
if (/\.bing\.com|go\.microsoft\.com/.test(url) || seen.has(url)) continue;
|
|
51
|
+
seen.add(url);
|
|
52
|
+
out.push({ url, title: m[2].replace(/<[^>]+>/g, "").trim() });
|
|
53
|
+
if (out.length >= limit) break;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A tool that searches the web (best-effort) and returns result URLs + titles. */
|
|
59
|
+
export function createWebSearchTool(): ToolDefinition {
|
|
60
|
+
return defineTool({
|
|
61
|
+
name: "web_search",
|
|
62
|
+
label: "Web Search",
|
|
63
|
+
description: "Search the web and return a list of result URLs and titles. Use before web_fetch to find sources.",
|
|
64
|
+
promptSnippet: "Search the web for sources",
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
query: Type.String({ description: "The search query." }),
|
|
67
|
+
count: Type.Optional(Type.Number({ description: "Max results (default 6)." })),
|
|
68
|
+
}),
|
|
69
|
+
async execute(_id, params: { query: string; count?: number }) {
|
|
70
|
+
const limit = Math.min(Math.max(params.count ?? 6, 1), 10);
|
|
71
|
+
try {
|
|
72
|
+
const { status, body } = await fetchText(`https://www.bing.com/search?q=${encodeURIComponent(params.query)}`);
|
|
73
|
+
const results = parseBingResults(body, limit);
|
|
74
|
+
const text = results.length
|
|
75
|
+
? results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n")
|
|
76
|
+
: `No results parsed (HTTP ${status}). Try a different query or fetch a known URL directly.`;
|
|
77
|
+
return { content: [{ type: "text", text }], details: { results } };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: `web_search failed: ${error instanceof Error ? error.message : error}` }],
|
|
81
|
+
details: { results: [] as Array<{ url: string; title: string }> },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}) as unknown as ToolDefinition;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A tool that fetches a URL and returns readable text. */
|
|
89
|
+
export function createWebFetchTool(maxChars = 6000): ToolDefinition {
|
|
90
|
+
return defineTool({
|
|
91
|
+
name: "web_fetch",
|
|
92
|
+
label: "Web Fetch",
|
|
93
|
+
description: "Fetch a URL and return its readable text content (HTML stripped, truncated).",
|
|
94
|
+
promptSnippet: "Fetch a URL's text",
|
|
95
|
+
parameters: Type.Object({
|
|
96
|
+
url: Type.String({ description: "The absolute URL to fetch." }),
|
|
97
|
+
}),
|
|
98
|
+
async execute(_id, params: { url: string }) {
|
|
99
|
+
try {
|
|
100
|
+
const { status, body } = await fetchText(params.url);
|
|
101
|
+
const text = htmlToText(body).slice(0, maxChars);
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: `HTTP ${status} ${params.url}\n\n${text}` }],
|
|
104
|
+
details: { status, url: params.url },
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: "text",
|
|
111
|
+
text: `web_fetch failed for ${params.url}: ${error instanceof Error ? error.message : error}`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
details: { status: 0, url: params.url },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}) as unknown as ToolDefinition;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Both web tools, for injecting into a research workflow's agents. */
|
|
122
|
+
export function createWebTools(): ToolDefinition[] {
|
|
123
|
+
return [createWebSearchTool(), createWebFetchTool()];
|
|
124
|
+
}
|