pi-taskflow 0.0.24 → 0.0.26
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 +110 -0
- package/extensions/cache.ts +6 -1
- package/extensions/flowir/hash.ts +97 -0
- package/extensions/flowir/index.ts +73 -0
- package/extensions/flowir/meta.ts +126 -0
- package/extensions/flowir/translate.ts +163 -0
- package/extensions/index.ts +292 -5
- package/extensions/interpolate.ts +17 -0
- package/extensions/runtime.ts +417 -49
- package/extensions/schema.ts +3 -1
- package/extensions/stale.ts +193 -0
- package/extensions/store.ts +25 -0
- package/package.json +1 -1
package/extensions/schema.ts
CHANGED
|
@@ -781,7 +781,7 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
|
|
|
781
781
|
return { ok: errors.length === 0, errors, warnings };
|
|
782
782
|
}
|
|
783
783
|
|
|
784
|
-
function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
|
|
784
|
+
export function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
|
|
785
785
|
const steps = new Set<string>();
|
|
786
786
|
const args = new Set<string>();
|
|
787
787
|
const scan = (s: string | undefined) => {
|
|
@@ -795,6 +795,8 @@ function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
|
|
|
795
795
|
scan(phase.task);
|
|
796
796
|
scan(phase.over);
|
|
797
797
|
scan(phase.when);
|
|
798
|
+
scan(phase.until);
|
|
799
|
+
for (const e of phase.eval ?? []) scan(e);
|
|
798
800
|
for (const b of phase.branches ?? []) scan(b.task);
|
|
799
801
|
for (const v of Object.values(phase.with ?? {})) if (typeof v === "string") scan(v);
|
|
800
802
|
for (const c of phase.context ?? []) scan(c);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-marking (M4) — conservative transitive invalidation over the observed
|
|
3
|
+
* readSet captured in M3.
|
|
4
|
+
*
|
|
5
|
+
* This is the "mark stale, don't rerun" half of overstory's cost-asymmetric
|
|
6
|
+
* reactivity (VISION §2.3): the cheap effects (figuring out what WOULD be
|
|
7
|
+
* invalidated) run for free; the expensive effects (actually re-running an LLM
|
|
8
|
+
* phase) are gated for M5. Given a run's observed readSets and a set of phases
|
|
9
|
+
* assumed to have changed, `computeStaleFrontier` returns the transitive
|
|
10
|
+
* closure of phases whose recorded dependencies are no longer trustworthy.
|
|
11
|
+
*
|
|
12
|
+
* Pure module: no IO, no Date, no randomness. Deterministic.
|
|
13
|
+
*
|
|
14
|
+
* Scope (honest): this is TOPOLOGICAL propagation only — a changed seed
|
|
15
|
+
* invalidates everything that (transitively) read it. The overstory
|
|
16
|
+
* "early cutoff" refinement (a re-run whose output HASH is unchanged does NOT
|
|
17
|
+
* invalidate, even if the version advanced) needs before/after content hashes,
|
|
18
|
+
* which only exist when a phase is actually re-run — that is the M5
|
|
19
|
+
* recomputation concern, deliberately out of scope here. Marking is the safe,
|
|
20
|
+
* conservative prerequisite that lets M5 rerun with confidence.
|
|
21
|
+
*
|
|
22
|
+
* @see docs/internal/overstory-convergence-roadmap.md §3 (M4)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PhaseState } from "./store.ts";
|
|
26
|
+
import { collectRefs, type Taskflow } from "./schema.ts";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Read graph
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** phaseId → the upstream stepIds it observed-reading (M3 PhaseState.reads). */
|
|
33
|
+
export type ReadMap = Map<string, readonly string[]>;
|
|
34
|
+
|
|
35
|
+
/** Fold a run's PhaseStates into a read map (drops phases with no reads). */
|
|
36
|
+
export function readMapOf(phases: Record<string, PhaseState>): ReadMap {
|
|
37
|
+
const m: ReadMap = new Map();
|
|
38
|
+
for (const [id, ps] of Object.entries(phases)) {
|
|
39
|
+
const deps = (ps.reads ?? []).map((r) => r.stepId);
|
|
40
|
+
if (deps.length) m.set(id, deps);
|
|
41
|
+
}
|
|
42
|
+
return m;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Phases that directly read `phaseId` (its immediate dependents).
|
|
46
|
+
*
|
|
47
|
+
* When `declared` is provided, the dependent set is the **union** of
|
|
48
|
+
* observed dependents (from `reads`) and declared dependents (from
|
|
49
|
+
* `declared`) — a declared-but-unobserved edge (e.g. a `when` ref that never
|
|
50
|
+
* fired) still counts as a dependency for staleness propagation (M2 union).
|
|
51
|
+
* `declared` undefined → observed-only (backward-compatible). */
|
|
52
|
+
export function dependentsOf(reads: ReadMap, phaseId: string, declared?: ReadMap): string[] {
|
|
53
|
+
const out = new Set<string>();
|
|
54
|
+
for (const [reader, deps] of reads) {
|
|
55
|
+
if (deps.includes(phaseId)) out.add(reader);
|
|
56
|
+
}
|
|
57
|
+
if (declared) {
|
|
58
|
+
for (const [reader, deps] of declared) {
|
|
59
|
+
if (deps.includes(phaseId)) out.add(reader);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...out];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Stale frontier (transitive closure, union semantics)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The set of phases that are stale if `seeds` change, transitively. A reader
|
|
71
|
+
* is stale if ANY phase it (observed- OR declared-)reading is stale
|
|
72
|
+
* (union/I5: when in doubt, assume dependency). Includes the seeds themselves.
|
|
73
|
+
*
|
|
74
|
+
* When `declared` is provided, the read graph used for propagation is the
|
|
75
|
+
* **union** of `reads` (observed, M3) and `declared` (M2 compile-time refs):
|
|
76
|
+
* a declared-but-unobserved edge still propagates staleness. `declared`
|
|
77
|
+
* undefined → observed-only (backward-compatible, identical to pre-M2).
|
|
78
|
+
*
|
|
79
|
+
* Deterministic. O(phases + read-edges). Cycles in the read graph (which a
|
|
80
|
+
* correct DAG can't produce, but a pathological one could) terminate because a
|
|
81
|
+
* phase is enqueued at most once.
|
|
82
|
+
*/
|
|
83
|
+
export function computeStaleFrontier(reads: ReadMap, seeds: Iterable<string>, declared?: ReadMap): Set<string> {
|
|
84
|
+
const stale = new Set<string>();
|
|
85
|
+
const queue: string[] = [...seeds];
|
|
86
|
+
while (queue.length) {
|
|
87
|
+
const s = queue.shift() as string;
|
|
88
|
+
if (stale.has(s)) continue;
|
|
89
|
+
stale.add(s);
|
|
90
|
+
for (const dep of dependentsOf(reads, s, declared)) {
|
|
91
|
+
if (!stale.has(dep)) queue.push(dep);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return stale;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Declared-plane derivation (M2)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Build a declared ReadMap from a flow definition: each phase's `collectRefs`
|
|
102
|
+
* `{steps.X}` refs become its declared reads (self-refs excluded so a loop
|
|
103
|
+
* `until` checking `{steps.thisId.output}` doesn't create a self-edge).
|
|
104
|
+
*
|
|
105
|
+
* Pure. Used by `recomputeTaskflow` and `/tf why-stale` so union (observed ∪
|
|
106
|
+
* declared) semantics apply to old runs too (pre-H1 runs have no persisted
|
|
107
|
+
* `RunState.declaredDeps` — deriving from `def` keeps recompute sound). */
|
|
108
|
+
export function declaredReadMapOfDef(def: Taskflow): ReadMap {
|
|
109
|
+
const m: ReadMap = new Map();
|
|
110
|
+
for (const p of def.phases) {
|
|
111
|
+
const refs = collectRefs(p);
|
|
112
|
+
const reads = refs.steps.filter((id) => id !== p.id);
|
|
113
|
+
if (reads.length) m.set(p.id, reads);
|
|
114
|
+
}
|
|
115
|
+
return m;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Rendering
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render either the full observed dependency graph (no seeds) or the stale
|
|
124
|
+
* frontier given assumed-changed seeds. Each stale phase lists the stale
|
|
125
|
+
* upstreams that caused it (its "why").
|
|
126
|
+
*
|
|
127
|
+
* When `declared` is provided, the frontier is the **union** (observed ∪
|
|
128
|
+
* declared) and a stale phase's "why" annotates edges present only in the
|
|
129
|
+
* declared plane (not observed at runtime) with `(declared)`. `declared`
|
|
130
|
+
* undefined → observed-only rendering (backward-compatible).
|
|
131
|
+
*/
|
|
132
|
+
export function formatWhyStale(
|
|
133
|
+
runId: string,
|
|
134
|
+
flowName: string,
|
|
135
|
+
reads: ReadMap,
|
|
136
|
+
seeds: readonly string[],
|
|
137
|
+
declared?: ReadMap,
|
|
138
|
+
): string {
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
lines.push(`why-stale — run ${runId} · flow "${flowName}"`);
|
|
141
|
+
lines.push("");
|
|
142
|
+
|
|
143
|
+
if (seeds.length === 0) {
|
|
144
|
+
// No seeds → show the full observed dependency graph (who reads what).
|
|
145
|
+
if (reads.size === 0 && (!declared || declared.size === 0)) {
|
|
146
|
+
lines.push("(No observed readSets in this run — provenance is empty.)");
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
lines.push("Observed dependency graph (who reads what):");
|
|
150
|
+
lines.push("");
|
|
151
|
+
const allReaders = new Set<string>([...reads.keys(), ...(declared?.keys() ?? [])]);
|
|
152
|
+
for (const reader of allReaders) {
|
|
153
|
+
const obs = reads.get(reader) ?? [];
|
|
154
|
+
const dec = declared?.get(reader) ?? [];
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
for (const d of obs) parts.push(d);
|
|
157
|
+
for (const d of dec) if (!obs.includes(d)) parts.push(`${d} (declared)`);
|
|
158
|
+
lines.push(`■ ${reader} reads: ${parts.join(", ") || "(none)"}`);
|
|
159
|
+
}
|
|
160
|
+
lines.push("");
|
|
161
|
+
lines.push("Pass a phase id to compute its stale frontier: /tf why-stale <runId> <phaseId>");
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const frontier = computeStaleFrontier(reads, seeds, declared);
|
|
166
|
+
const seedSet = new Set(seeds);
|
|
167
|
+
lines.push(`Assuming changed: ${[...seedSet].join(", ")}`);
|
|
168
|
+
lines.push("");
|
|
169
|
+
if (frontier.size <= seedSet.size) {
|
|
170
|
+
lines.push(`Stale frontier: only the seed(s) themselves — nothing else reads them.`);
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
173
|
+
lines.push(`Stale frontier (transitive, ${frontier.size} phases):`);
|
|
174
|
+
// Order: seeds first, then the rest, for readability.
|
|
175
|
+
const ordered = [...seeds.filter((s) => frontier.has(s)), ...[...frontier].filter((s) => !seedSet.has(s))];
|
|
176
|
+
for (const id of ordered) {
|
|
177
|
+
if (seedSet.has(id)) {
|
|
178
|
+
lines.push(` ■ ${id} (changed — seed)`);
|
|
179
|
+
} else {
|
|
180
|
+
// Why is it stale? The stale upstreams it read (observed ∪ declared).
|
|
181
|
+
const obs = reads.get(id) ?? [];
|
|
182
|
+
const dec = declared?.get(id) ?? [];
|
|
183
|
+
const obsCauses = obs.filter((d) => frontier.has(d));
|
|
184
|
+
const decCauses = dec.filter((d) => frontier.has(d) && !obs.includes(d));
|
|
185
|
+
const causeStr = [
|
|
186
|
+
...obsCauses,
|
|
187
|
+
...decCauses.map((d) => `${d} (declared)`),
|
|
188
|
+
].join(", ");
|
|
189
|
+
lines.push(` ■ ${id} ← reads ${causeStr || "(nothing stale?)"}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
package/extensions/store.ts
CHANGED
|
@@ -21,6 +21,7 @@ import * as path from "node:path";
|
|
|
21
21
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import type { Taskflow } from "./schema.ts";
|
|
23
23
|
import type { UsageStats } from "./usage.ts";
|
|
24
|
+
import type { DeclaredDeps } from "./flowir/meta.ts";
|
|
24
25
|
|
|
25
26
|
export interface SavedFlow {
|
|
26
27
|
name: string;
|
|
@@ -70,6 +71,14 @@ export interface PhaseState {
|
|
|
70
71
|
/** Non-fatal diagnostic warnings accumulated during this phase (e.g.
|
|
71
72
|
* unresolved interpolation placeholders, suspicious templates). */
|
|
72
73
|
warnings?: string[];
|
|
74
|
+
/** Observed readSet (M3): the upstream phase outputs this phase actually
|
|
75
|
+
* consumed at interpolation time — not what it *declared* to depend on
|
|
76
|
+
* (dependsOn), but what it truly *read* (`{steps.X...}`). Each entry
|
|
77
|
+
* carries the version (= the read phase's inputHash) it consumed, so a
|
|
78
|
+
* later staleness check (M4/M5) can tell whether the upstream has moved.
|
|
79
|
+
* This is the overstory "observed readSet@version" moat: no other
|
|
80
|
+
* orchestrator records what a result actually depended on. */
|
|
81
|
+
reads?: Array<{ stepId: string; version?: string }>;
|
|
73
82
|
/** Truncated previews of interpolated strings used to execute this phase,
|
|
74
83
|
* useful when diagnosing why a model saw a literal placeholder. */
|
|
75
84
|
interpolation?: Array<{ source: string; text: string; missing?: string[] }>;
|
|
@@ -89,6 +98,22 @@ export interface RunState {
|
|
|
89
98
|
pid?: number;
|
|
90
99
|
/** True for runs spawned via `detach: true` (background execution). */
|
|
91
100
|
detached?: boolean;
|
|
101
|
+
/** Content fingerprint of the desugared flow definition (overstory hash
|
|
102
|
+
* algorithm). Folded into every phase's cache key so a structural change
|
|
103
|
+
* to the flow always invalidates cross-run cache hits — and an identical
|
|
104
|
+
* re-run always reuses them. Filled once at run start; persisted for
|
|
105
|
+
* audit/resume consistency. */
|
|
106
|
+
flowDefHash?: string | "failed";
|
|
107
|
+
/** Per-phase *declared* dependency footprint (M2), synthesized at compile
|
|
108
|
+
* time from `{steps.X}` interpolation refs via `compileTaskflowToIR`.
|
|
109
|
+
* This is the *declared* plane — distinct from the *observed* readSet
|
|
110
|
+
* (`PhaseState.reads`, captured at runtime). Recompute staleness uses the
|
|
111
|
+
* **union** (observed ∪ declared) so a declared-but-unobserved edge (e.g.
|
|
112
|
+
* a `when` ref that never fired) still propagates. JSON-safe `Record`
|
|
113
|
+
* shape so it round-trips through persistence. Audit/provenance only —
|
|
114
|
+
* recompute derives this fresh from `def` so old runs (pre-H1) also get
|
|
115
|
+
* union semantics. */
|
|
116
|
+
declaredDeps?: Record<string, DeclaredDeps>;
|
|
92
117
|
}
|
|
93
118
|
|
|
94
119
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26",
|
|
4
4
|
"description": "A declarative, verifiable graph of task nodes for the Pi coding agent — not a workflow you script, but a DAG you declare: statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|