@garygentry/feature-forge 0.3.5 → 0.3.6
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/README.md +1 -1
- package/adapters/claude/.feature-forge-bundle.json +1 -1
- package/adapters/claude/references/forge-config-schema.json +4 -4
- package/adapters/claude/references/ralph-loop-contract.md +12 -8
- package/adapters/claude/references/shared-conventions.md +1 -1
- package/adapters/claude/scripts/forge-session.py +30 -26
- package/adapters/claude/skills/forge/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-5-loop/SKILL.md +3 -3
- package/adapters/claude/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
- package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +2 -2
- package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-fix/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +4 -4
- package/adapters/claude/skills/forge-guide/references/ralph-loop-contract.md +12 -8
- package/adapters/claude/skills/forge-guide/references/shared-conventions.md +1 -1
- package/adapters/claude/skills/forge-verify/SKILL.md +5 -3
- package/adapters/claude/skills/forge-verify/references/findings-template.md +6 -6
- package/adapters/claude/skills/forge-verify/references/shared-conventions.md +1 -1
- package/adapters/codex/.feature-forge-bundle.json +1 -1
- package/adapters/codex/references/forge-config-schema.json +4 -4
- package/adapters/codex/references/ralph-loop-contract.md +12 -8
- package/adapters/codex/references/shared-conventions.md +1 -1
- package/adapters/codex/scripts/forge-session.py +30 -26
- package/adapters/codex/skills/forge/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-5-loop/SKILL.md +3 -3
- package/adapters/codex/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
- package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +2 -2
- package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-fix/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +4 -4
- package/adapters/codex/skills/forge-guide/references/ralph-loop-contract.md +12 -8
- package/adapters/codex/skills/forge-guide/references/shared-conventions.md +1 -1
- package/adapters/codex/skills/forge-verify/SKILL.md +5 -3
- package/adapters/codex/skills/forge-verify/references/findings-template.md +6 -6
- package/adapters/codex/skills/forge-verify/references/shared-conventions.md +1 -1
- package/adapters/copilot/.feature-forge-bundle.json +1 -1
- package/adapters/copilot/references/forge-config-schema.json +4 -4
- package/adapters/copilot/references/ralph-loop-contract.md +12 -8
- package/adapters/copilot/references/shared-conventions.md +1 -1
- package/adapters/copilot/scripts/forge-session.py +30 -26
- package/adapters/copilot/skills/forge/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +3 -3
- package/adapters/copilot/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
- package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +2 -2
- package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +4 -4
- package/adapters/copilot/skills/forge-guide/references/ralph-loop-contract.md +12 -8
- package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +1 -1
- package/adapters/copilot/skills/forge-verify/forge-verify.md +5 -3
- package/adapters/copilot/skills/forge-verify/references/findings-template.md +6 -6
- package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +1 -1
- package/adapters/cursor/.feature-forge-bundle.json +1 -1
- package/adapters/cursor/references/forge-config-schema.json +4 -4
- package/adapters/cursor/references/ralph-loop-contract.md +12 -8
- package/adapters/cursor/references/shared-conventions.md +1 -1
- package/adapters/cursor/scripts/forge-session.py +30 -26
- package/adapters/cursor/skills/forge/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +3 -3
- package/adapters/cursor/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
- package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +2 -2
- package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +4 -4
- package/adapters/cursor/skills/forge-guide/references/ralph-loop-contract.md +12 -8
- package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +1 -1
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +5 -3
- package/adapters/cursor/skills/forge-verify/references/findings-template.md +6 -6
- package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +1 -1
- package/adapters/gemini/.feature-forge-bundle.json +1 -1
- package/adapters/gemini/gemini-extension.json +1 -1
- package/adapters/gemini/references/forge-config-schema.json +4 -4
- package/adapters/gemini/references/ralph-loop-contract.md +12 -8
- package/adapters/gemini/references/shared-conventions.md +1 -1
- package/adapters/gemini/scripts/forge-session.py +30 -26
- package/adapters/gemini/skills/forge/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +3 -3
- package/adapters/gemini/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
- package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +2 -2
- package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +4 -4
- package/adapters/gemini/skills/forge-guide/references/ralph-loop-contract.md +12 -8
- package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +1 -1
- package/adapters/gemini/skills/forge-verify/forge-verify.md +5 -3
- package/adapters/gemini/skills/forge-verify/references/findings-template.md +6 -6
- package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +1 -1
- package/adapters/pi/.feature-forge-bundle.json +1 -1
- package/adapters/pi/extensions/forge-loop-supervisor/events.ts +90 -0
- package/adapters/pi/extensions/forge-loop-supervisor/index.ts +114 -0
- package/adapters/pi/extensions/forge-loop-supervisor/registry.ts +137 -0
- package/adapters/pi/extensions/forge-loop-supervisor/supervisor.ts +185 -0
- package/adapters/pi/extensions/forge-loop-supervisor/tailer.ts +137 -0
- package/adapters/pi/extensions/forge-loop-supervisor/types.ts +87 -0
- package/adapters/pi/extensions/forge-loop-supervisor/wiring.ts +423 -0
- package/adapters/pi/package.json +2 -1
- package/adapters/pi/references/forge-config-schema.json +4 -4
- package/adapters/pi/references/ralph-loop-contract.md +12 -8
- package/adapters/pi/references/shared-conventions.md +1 -1
- package/adapters/pi/scripts/forge-session.py +30 -26
- package/adapters/pi/skills/forge/SKILL.md +4 -1
- package/adapters/pi/skills/forge/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-0-epic/SKILL.md +4 -1
- package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-1-prd/SKILL.md +4 -1
- package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-2-tech/SKILL.md +4 -1
- package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-3-specs/SKILL.md +4 -1
- package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-4-backlog/SKILL.md +4 -1
- package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-5-loop/SKILL.md +14 -8
- package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
- package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +8 -5
- package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-6-docs/SKILL.md +4 -1
- package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-bootstrap/SKILL.md +4 -1
- package/adapters/pi/skills/forge-fix/SKILL.md +4 -1
- package/adapters/pi/skills/forge-fix/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-guide/SKILL.md +4 -1
- package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +4 -4
- package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +12 -8
- package/adapters/pi/skills/forge-guide/references/shared-conventions.md +1 -1
- package/adapters/pi/skills/forge-init/SKILL.md +4 -1
- package/adapters/pi/skills/forge-verify/SKILL.md +9 -4
- package/adapters/pi/skills/forge-verify/references/findings-template.md +6 -6
- package/adapters/pi/skills/forge-verify/references/shared-conventions.md +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/rauf.d.ts +3 -3
- package/dist/rauf.js +2 -2
- package/dist/types.d.ts +1 -1
- package/package.json +8 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/index.ts
|
|
2
|
+
// Regenerate with: python3 scripts/build-adapters.py
|
|
3
|
+
/**
|
|
4
|
+
* forge-loop-supervisor — a first-party Pi extension that lets forge-5-loop run
|
|
5
|
+
* the rauf autonomous coding loop WITHOUT blocking the Pi session, and supervises
|
|
6
|
+
* its native event stream.
|
|
7
|
+
*
|
|
8
|
+
* Pi has no built-in background bash, persistent monitor, or push-notification
|
|
9
|
+
* surface, so the generic Claude-first forge-5-loop contract (background the
|
|
10
|
+
* process, arm a `Monitor` on events.ndjson, `PushNotification` on exceptions)
|
|
11
|
+
* cannot be followed literally on Pi. This extension provides the real mechanism:
|
|
12
|
+
* `forge_loop_launch` starts the runner detached (it runs in rauf's server and
|
|
13
|
+
* outlives the session), then a rotation-aware NDJSON watcher turns each
|
|
14
|
+
* `item_completed` into a quiet progress line and wakes the session — via
|
|
15
|
+
* `pi.sendMessage(..., { triggerTurn: true })` — only on needs-human / blocked /
|
|
16
|
+
* stuck / review-failed / error / completion. `forge_loop_status` and
|
|
17
|
+
* `forge_loop_stop` round out the launch/attach/status/stop surface. Task
|
|
18
|
+
* identity is persisted (session entry + a file mirror beside the runner state)
|
|
19
|
+
* so a restarted session reattaches without duplicate reporting, and shutdown
|
|
20
|
+
* tears down watchers only — never the runner.
|
|
21
|
+
*
|
|
22
|
+
* The pi-facing glue is thin: all logic lives in the injectable {@link
|
|
23
|
+
* createExtension} factory (see wiring.ts), so the extension is unit-tested with
|
|
24
|
+
* a fake pi and fake deps. This file supplies the production dependencies.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { watch as fsWatch } from "node:fs";
|
|
30
|
+
import { basename, dirname } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { createExtension, type Deps, type PiLike, type WatchHandle } from "./wiring.js";
|
|
33
|
+
|
|
34
|
+
/** How often the backstop poll fires (ms). fs.watch can miss events or drop on
|
|
35
|
+
* some platforms; a low-frequency interval guarantees the tail keeps up without
|
|
36
|
+
* busy-waiting. */
|
|
37
|
+
const BACKSTOP_MS = 2000;
|
|
38
|
+
/** Debounce window (ms) coalescing a burst of fs.watch events into one poll. */
|
|
39
|
+
const DEBOUNCE_MS = 120;
|
|
40
|
+
|
|
41
|
+
const productionDeps: Deps = {
|
|
42
|
+
spawnDetached(bin, args, cwd, onError) {
|
|
43
|
+
// detached + unref + ignored stdio: the child is fully decoupled from the
|
|
44
|
+
// Pi process and survives session shutdown. rauf hands the loop to its
|
|
45
|
+
// server and this launcher exits quickly; a non-self-detaching runner still
|
|
46
|
+
// stays off the session.
|
|
47
|
+
const child = spawn(bin, args, { cwd, detached: true, stdio: "ignore" });
|
|
48
|
+
// ENOENT (bad bin) and other spawn failures arrive here asynchronously —
|
|
49
|
+
// report them so a launch that never happened does not look successful.
|
|
50
|
+
child.on("error", (err) => onError?.(err instanceof Error ? err.message : String(err)));
|
|
51
|
+
child.unref();
|
|
52
|
+
},
|
|
53
|
+
watch(filePath, onChange): WatchHandle {
|
|
54
|
+
const dir = dirname(filePath);
|
|
55
|
+
const base = basename(filePath);
|
|
56
|
+
let debounce: NodeJS.Timeout | null = null;
|
|
57
|
+
const fire = () => {
|
|
58
|
+
if (debounce) return;
|
|
59
|
+
debounce = setTimeout(() => {
|
|
60
|
+
debounce = null;
|
|
61
|
+
onChange();
|
|
62
|
+
}, DEBOUNCE_MS);
|
|
63
|
+
};
|
|
64
|
+
let fsw: ReturnType<typeof fsWatch> | null = null;
|
|
65
|
+
try {
|
|
66
|
+
// Watch the DIRECTORY (not the file) so rotation-by-rename — rauf moves
|
|
67
|
+
// events.ndjson into archive/ and recreates it each run — keeps firing.
|
|
68
|
+
fsw = fsWatch(dir, (_evt, name) => {
|
|
69
|
+
if (!name || name === base) fire();
|
|
70
|
+
});
|
|
71
|
+
fsw.on?.("error", () => {
|
|
72
|
+
/* directory vanished; the backstop interval keeps polling */
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
fsw = null;
|
|
76
|
+
}
|
|
77
|
+
const interval = setInterval(onChange, BACKSTOP_MS);
|
|
78
|
+
return {
|
|
79
|
+
close() {
|
|
80
|
+
try {
|
|
81
|
+
fsw?.close();
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
if (debounce) clearTimeout(debounce);
|
|
86
|
+
clearInterval(interval);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
now: () => new Date().toISOString(),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export default function (pi: ExtensionAPI) {
|
|
94
|
+
// Adapt the real ExtensionAPI to the structural PiLike the wiring consumes.
|
|
95
|
+
// Each member is accessed by name off the concrete `pi`, so a method renamed
|
|
96
|
+
// or removed in a future pi is a COMPILE error here (not a silent runtime
|
|
97
|
+
// failure hidden behind a blanket cast) — while PiLike stays loose enough for
|
|
98
|
+
// the fake pi in tests. Argument casts bridge PiLike's minimal shapes to pi's
|
|
99
|
+
// stricter generic signatures; the pi-API contract this was built against
|
|
100
|
+
// pins the exact shapes.
|
|
101
|
+
const piLike: PiLike = {
|
|
102
|
+
registerTool: (def) => (pi.registerTool as (d: unknown) => void)(def),
|
|
103
|
+
on: (event, handler) =>
|
|
104
|
+
(pi.on as unknown as (e: string, h: (ev: unknown, ctx: unknown) => void | Promise<void>) => void)(event, handler),
|
|
105
|
+
sendMessage: (message, options) =>
|
|
106
|
+
pi.sendMessage(message as Parameters<typeof pi.sendMessage>[0], options),
|
|
107
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
108
|
+
exec:
|
|
109
|
+
typeof pi.exec === "function"
|
|
110
|
+
? (command, args, opts) => pi.exec(command, args, opts)
|
|
111
|
+
: undefined,
|
|
112
|
+
};
|
|
113
|
+
createExtension(piLike, productionDeps);
|
|
114
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/registry.ts
|
|
2
|
+
// Regenerate with: python3 scripts/build-adapters.py
|
|
3
|
+
/**
|
|
4
|
+
* Durable task registry — a small JSON mirror of the supervised task, written
|
|
5
|
+
* beside the runner's own state so a brand-new Pi session (or a full pi restart,
|
|
6
|
+
* where the session file itself differs) can rediscover a loop it did not launch.
|
|
7
|
+
*
|
|
8
|
+
* Why a file and not only `pi.appendEntry`: appendEntry records survive session
|
|
9
|
+
* reload/branch within pi, but a cold restart may open a different session file.
|
|
10
|
+
* The mirror lives at `<stateDir>/.forge-supervisor.json`, next to rauf's own
|
|
11
|
+
* `state.json` / `events.ndjson`, so it is discoverable from the backlog dir
|
|
12
|
+
* alone. rauf's detached loop is server-managed (no single child pid to signal),
|
|
13
|
+
* so liveness is judged from the event stream and `rauf status --json`, never a
|
|
14
|
+
* tracked pid — the mirror deliberately stores no pid.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
renameSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
statSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
|
|
29
|
+
import type { SupervisorTask } from "./types.js";
|
|
30
|
+
|
|
31
|
+
/** The mirror filename, discoverable by scanning (see {@link discoverMirrors}). */
|
|
32
|
+
export const MIRROR_NAME = ".forge-supervisor.json";
|
|
33
|
+
|
|
34
|
+
/** Directory names never worth descending into during a mirror scan. */
|
|
35
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "archive", ".pi", ".claude"]);
|
|
36
|
+
/** How deep the scan descends from the project root — mirrors live at
|
|
37
|
+
* `<backlogDir>/.rauf/.forge-supervisor.json`, so a shallow bound reaches the
|
|
38
|
+
* common `specs/<feature>/.rauf/` layout without walking the whole tree. */
|
|
39
|
+
const SCAN_MAX_DEPTH = 5;
|
|
40
|
+
|
|
41
|
+
/** Path of the registry mirror for a runner state directory. */
|
|
42
|
+
export function mirrorPath(stateDir: string): string {
|
|
43
|
+
return join(stateDir, MIRROR_NAME);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the mirrored task for a state dir, or null if absent/unreadable/corrupt.
|
|
47
|
+
* A corrupt mirror is treated as "no task" rather than throwing — a half-written
|
|
48
|
+
* file must never wedge session startup. */
|
|
49
|
+
export function readMirror(stateDir: string): SupervisorTask | null {
|
|
50
|
+
const path = mirrorPath(stateDir);
|
|
51
|
+
if (!existsSync(path)) return null;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<SupervisorTask>;
|
|
54
|
+
if (
|
|
55
|
+
parsed &&
|
|
56
|
+
typeof parsed.stateDir === "string" &&
|
|
57
|
+
typeof parsed.eventsFile === "string" &&
|
|
58
|
+
typeof parsed.backlogDir === "string"
|
|
59
|
+
) {
|
|
60
|
+
return {
|
|
61
|
+
backlogDir: parsed.backlogDir,
|
|
62
|
+
stateDir: parsed.stateDir,
|
|
63
|
+
eventsFile: parsed.eventsFile,
|
|
64
|
+
launchedAt: typeof parsed.launchedAt === "string" ? parsed.launchedAt : "",
|
|
65
|
+
total: typeof parsed.total === "number" ? parsed.total : undefined,
|
|
66
|
+
eventsIno: typeof parsed.eventsIno === "number" ? parsed.eventsIno : undefined,
|
|
67
|
+
lastSeq: typeof parsed.lastSeq === "number" ? parsed.lastSeq : -1,
|
|
68
|
+
closed: parsed.closed === true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// fall through — corrupt mirror is "no task"
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Persist the task mirror atomically (write-temp + rename), creating the state
|
|
78
|
+
* dir if the launcher raced ahead of the runner. */
|
|
79
|
+
export function writeMirror(task: SupervisorTask): void {
|
|
80
|
+
const path = mirrorPath(task.stateDir);
|
|
81
|
+
try {
|
|
82
|
+
// Stamp the CURRENT events-file inode so a later session can tell whether the
|
|
83
|
+
// file was rotated (a new run) while it was away — see SupervisorTask.eventsIno.
|
|
84
|
+
let eventsIno = task.eventsIno;
|
|
85
|
+
try {
|
|
86
|
+
eventsIno = statSync(task.eventsFile).ino;
|
|
87
|
+
} catch {
|
|
88
|
+
// events file not present yet — keep whatever the task carried (if any)
|
|
89
|
+
}
|
|
90
|
+
const record: SupervisorTask = { ...task, eventsIno };
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
92
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
93
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
94
|
+
renameSync(tmp, path);
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort durability; a failed mirror write must not break the tool.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove the mirror (on an explicit stop of a task the session owns). */
|
|
101
|
+
export function clearMirror(stateDir: string): void {
|
|
102
|
+
try {
|
|
103
|
+
rmSync(mirrorPath(stateDir), { force: true });
|
|
104
|
+
} catch {
|
|
105
|
+
// best-effort
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find every supervised-task mirror under `root`, so a BRAND-NEW Pi session (a
|
|
111
|
+
* fresh session file with no `forge-loop-task` entry of its own) can still
|
|
112
|
+
* rediscover and reattach to a loop a previous session launched. Bounded, cheap,
|
|
113
|
+
* and failure-tolerant: skips heavy directories, caps depth, and treats any
|
|
114
|
+
* unreadable dir or corrupt mirror as absent rather than throwing.
|
|
115
|
+
*/
|
|
116
|
+
export function discoverMirrors(root: string): SupervisorTask[] {
|
|
117
|
+
const found: SupervisorTask[] = [];
|
|
118
|
+
const walk = (dir: string, depth: number): void => {
|
|
119
|
+
if (depth > SCAN_MAX_DEPTH) return;
|
|
120
|
+
let entries: import("node:fs").Dirent[];
|
|
121
|
+
try {
|
|
122
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.isFile() && entry.name === MIRROR_NAME) {
|
|
128
|
+
const task = readMirror(dir); // mirror lives AT <stateDir>/<MIRROR_NAME>
|
|
129
|
+
if (task) found.push(task);
|
|
130
|
+
} else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
131
|
+
walk(join(dir, entry.name), depth + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(root, 0);
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/supervisor.ts
|
|
2
|
+
// Regenerate with: python3 scripts/build-adapters.py
|
|
3
|
+
/**
|
|
4
|
+
* LoopSupervisor — the host-agnostic core that turns a stream of rauf events
|
|
5
|
+
* into the right user-facing behavior, with exactly-once reporting across
|
|
6
|
+
* session restarts.
|
|
7
|
+
*
|
|
8
|
+
* Reporting rule (issue #236):
|
|
9
|
+
* - routine `item_completed` → one quiet deterministic line, no model turn;
|
|
10
|
+
* - exception (`needs_human` / block / stuck / review-failed / error /
|
|
11
|
+
* cancellation) → notify AND wake the session;
|
|
12
|
+
* - terminal (`loop_completed` / error / cancelled) → wake the session so the
|
|
13
|
+
* loop stage runs its post-run close-out, then stop watching.
|
|
14
|
+
*
|
|
15
|
+
* Dedup + reattach: every rauf record carries a monotonic per-run `seq`. A task
|
|
16
|
+
* persists the highest `seq` it has already surfaced (`lastSeq`). When a fresh
|
|
17
|
+
* Pi session reattaches, its tailer re-reads events.ndjson from the top; records
|
|
18
|
+
* with `seq <= lastSeq` are REPLAYED SILENTLY — they still rebuild the in-memory
|
|
19
|
+
* `done` counter, but they are never re-notified — while records past `lastSeq`
|
|
20
|
+
* are surfaced live. That is what lets a restart reconcile a running loop without
|
|
21
|
+
* duplicate reports.
|
|
22
|
+
*
|
|
23
|
+
* Duplicate watchers are prevented by keying active tasks on their stateDir: a
|
|
24
|
+
* second `attach` for the same stateDir returns the existing handle.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { classifyEvent, formatProgress, formatSignal } from "./events.js";
|
|
28
|
+
import type { RaufEvent, SupervisorHost, SupervisorTask } from "./types.js";
|
|
29
|
+
|
|
30
|
+
/** A live watch handle the host drives: `poll()` on file change, `close()` on
|
|
31
|
+
* teardown. Reattach-safe and idempotent. */
|
|
32
|
+
export interface TaskHandle {
|
|
33
|
+
poll(): void;
|
|
34
|
+
close(): void;
|
|
35
|
+
readonly stateDir: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ActiveTask {
|
|
39
|
+
task: SupervisorTask;
|
|
40
|
+
/** Running count of completed items, rebuilt from replayed history. */
|
|
41
|
+
done: number;
|
|
42
|
+
/** Highest item_completed seq already counted into `done` — guards the counter
|
|
43
|
+
* against a re-read double-count independently of the notify cursor. */
|
|
44
|
+
doneCursor: number;
|
|
45
|
+
/** Total backlog items, when the launcher recorded it (`task.total`). */
|
|
46
|
+
total?: number;
|
|
47
|
+
closed: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class LoopSupervisor {
|
|
51
|
+
private readonly active = new Map<string, ActiveTask>();
|
|
52
|
+
|
|
53
|
+
constructor(private readonly host: SupervisorHost) {}
|
|
54
|
+
|
|
55
|
+
/** Whether a task for this stateDir is already being supervised. */
|
|
56
|
+
isActive(stateDir: string): boolean {
|
|
57
|
+
return this.active.has(stateDir);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Snapshot of the current progress for a task (for the status tool). */
|
|
61
|
+
progress(stateDir: string): { done: number; total?: number; closed: boolean } | null {
|
|
62
|
+
const a = this.active.get(stateDir);
|
|
63
|
+
return a ? { done: a.done, total: a.total, closed: a.closed } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Begin (or reattach) supervision of one task. The returned handle's `poll`
|
|
68
|
+
* feeds new NDJSON records through {@link handleRecord}; the host is expected
|
|
69
|
+
* to call it on file-change and once immediately (to replay history). A
|
|
70
|
+
* second attach for the same stateDir is a no-op that returns the live handle
|
|
71
|
+
* — the dedup guard against duplicate watchers.
|
|
72
|
+
*
|
|
73
|
+
* @param makeReader Builds a reader (typically an NdjsonTailer bound to
|
|
74
|
+
* `task.eventsFile`) whose `poll` dispatches each parsed record to `onRecord`.
|
|
75
|
+
*/
|
|
76
|
+
attach(
|
|
77
|
+
task: SupervisorTask,
|
|
78
|
+
makeReader: (
|
|
79
|
+
onRecord: (rec: RaufEvent) => void,
|
|
80
|
+
onRotate: () => void,
|
|
81
|
+
) => { poll(): void },
|
|
82
|
+
): TaskHandle {
|
|
83
|
+
const existing = this.active.get(task.stateDir);
|
|
84
|
+
if (existing) return this.handleFor(task.stateDir);
|
|
85
|
+
|
|
86
|
+
const entry: ActiveTask = {
|
|
87
|
+
task: { ...task },
|
|
88
|
+
done: 0,
|
|
89
|
+
doneCursor: -1,
|
|
90
|
+
total: task.total,
|
|
91
|
+
closed: task.closed,
|
|
92
|
+
};
|
|
93
|
+
this.active.set(task.stateDir, entry);
|
|
94
|
+
const reader = makeReader(
|
|
95
|
+
(rec) => this.handleRecord(task.stateDir, rec),
|
|
96
|
+
() => this.handleRotate(task.stateDir),
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
stateDir: task.stateDir,
|
|
100
|
+
poll: () => reader.poll(),
|
|
101
|
+
close: () => this.detach(task.stateDir),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stop tracking a task in memory (watcher teardown / stop). Does NOT touch
|
|
106
|
+
* the detached runner — the loop is server-owned and outlives the session. */
|
|
107
|
+
detach(stateDir: string): void {
|
|
108
|
+
this.active.delete(stateDir);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Process one parsed rauf record for a task: dedup, classify, dispatch. */
|
|
112
|
+
private handleRecord(stateDir: string, rec: RaufEvent): void {
|
|
113
|
+
const entry = this.active.get(stateDir);
|
|
114
|
+
if (!entry) return;
|
|
115
|
+
const seq = typeof rec.seq === "number" ? rec.seq : null;
|
|
116
|
+
const alreadySeen = seq !== null && seq <= entry.task.lastSeq;
|
|
117
|
+
const cls = classifyEvent(rec);
|
|
118
|
+
|
|
119
|
+
// Count completed items for the [N/M] line. Guard against double-counting a
|
|
120
|
+
// replayed item: only count when its seq is past the counter's cursor. On a
|
|
121
|
+
// fresh attach the cursor is -1 so the whole history counts; on a re-read of
|
|
122
|
+
// the same run (a spurious re-poll) already-counted items are skipped. A
|
|
123
|
+
// genuine new run arrives via handleRotate, which resets the cursor first.
|
|
124
|
+
if (rec.type === "item_completed") {
|
|
125
|
+
if (seq === null || seq > entry.doneCursor) {
|
|
126
|
+
entry.done += 1;
|
|
127
|
+
if (seq !== null) entry.doneCursor = seq;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (alreadySeen) return; // replayed history — rebuilt state, never re-notify
|
|
132
|
+
|
|
133
|
+
let surfaced = true;
|
|
134
|
+
switch (cls) {
|
|
135
|
+
case "progress":
|
|
136
|
+
this.host.notify(formatProgress(rec, entry.done, entry.total), "info");
|
|
137
|
+
break;
|
|
138
|
+
case "exception":
|
|
139
|
+
this.host.notify(formatSignal(rec), "warning");
|
|
140
|
+
this.host.wake(formatSignal(rec));
|
|
141
|
+
break;
|
|
142
|
+
case "terminal":
|
|
143
|
+
this.host.notify(formatSignal(rec), "info");
|
|
144
|
+
this.host.wake(formatSignal(rec));
|
|
145
|
+
entry.closed = true;
|
|
146
|
+
entry.task.closed = true;
|
|
147
|
+
break;
|
|
148
|
+
case "ignore":
|
|
149
|
+
surfaced = false;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Advance the cursor and persist ONLY for a surfaced event. A firehose
|
|
154
|
+
// (`ignore`) event must not trigger a disk write + session entry per record
|
|
155
|
+
// (rauf emits many per second); ignored events re-classify as `ignore` on
|
|
156
|
+
// any later replay, so not persisting their seq is harmless. A terminal
|
|
157
|
+
// event persists `closed`, letting a later session know the run is done.
|
|
158
|
+
if (surfaced && seq !== null && seq > entry.task.lastSeq) {
|
|
159
|
+
entry.task.lastSeq = seq;
|
|
160
|
+
this.host.persist({ ...entry.task });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A rotation was detected (rauf started a new run — event `seq` restarts at
|
|
165
|
+
* 0). Reset the per-run cursor and counters so the new run is surfaced from
|
|
166
|
+
* its start instead of being swallowed by the previous run's high-water seq. */
|
|
167
|
+
private handleRotate(stateDir: string): void {
|
|
168
|
+
const entry = this.active.get(stateDir);
|
|
169
|
+
if (!entry) return;
|
|
170
|
+
entry.task.lastSeq = -1;
|
|
171
|
+
entry.doneCursor = -1;
|
|
172
|
+
entry.done = 0;
|
|
173
|
+
entry.closed = false;
|
|
174
|
+
entry.task.closed = false;
|
|
175
|
+
this.host.persist({ ...entry.task });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private handleFor(stateDir: string): TaskHandle {
|
|
179
|
+
return {
|
|
180
|
+
stateDir,
|
|
181
|
+
poll: () => {},
|
|
182
|
+
close: () => this.detach(stateDir),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/tailer.ts
|
|
2
|
+
// Regenerate with: python3 scripts/build-adapters.py
|
|
3
|
+
/**
|
|
4
|
+
* NdjsonTailer — a rotation-aware, malformed-tolerant tail of an append-only
|
|
5
|
+
* NDJSON file, decoupled from any watch mechanism so it is unit-testable by
|
|
6
|
+
* driving {@link NdjsonTailer.poll} against a temp file.
|
|
7
|
+
*
|
|
8
|
+
* rauf is the single writer of `events.ndjson` and rotates it at the START of
|
|
9
|
+
* each run — the old file is moved to `<stateDir>/archive/{ts}-events.ndjson`
|
|
10
|
+
* and a fresh empty file takes its place (rauf `packages/core/src/events-log.ts`).
|
|
11
|
+
* So a live supervisor must survive the file being replaced by name mid-watch.
|
|
12
|
+
* Two independent signals catch a rotation: the file's inode changing, and its
|
|
13
|
+
* size shrinking below our read cursor. Either resets the cursor to 0 and
|
|
14
|
+
* re-reads from the top of the new file.
|
|
15
|
+
*
|
|
16
|
+
* Reads are incremental from a byte offset (never re-reading the whole file), a
|
|
17
|
+
* trailing partial line with no newline yet is buffered until its newline
|
|
18
|
+
* arrives, and a line that is not valid JSON is skipped rather than throwing —
|
|
19
|
+
* a half-flushed or corrupt record must never stall the tail.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
23
|
+
import { StringDecoder } from "node:string_decoder";
|
|
24
|
+
|
|
25
|
+
import type { RaufEvent } from "./types.js";
|
|
26
|
+
|
|
27
|
+
export class NdjsonTailer {
|
|
28
|
+
private offset = 0;
|
|
29
|
+
private ino: number | null = null;
|
|
30
|
+
private partial = "";
|
|
31
|
+
// A StringDecoder holds back an incomplete trailing multibyte sequence between
|
|
32
|
+
// reads, so a UTF-8 character split across two polls (e.g. an accented char or
|
|
33
|
+
// emoji in an event's `reason`/`title`) decodes correctly instead of turning
|
|
34
|
+
// into replacement chars on each half.
|
|
35
|
+
private decoder = new StringDecoder("utf8");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param filePath Absolute path to the NDJSON file (may not exist yet).
|
|
39
|
+
* @param onRecord Called once per successfully-parsed JSON line, in order.
|
|
40
|
+
* @param onError Optional; called with a short reason when a line is
|
|
41
|
+
* skipped as malformed (for observability/tests).
|
|
42
|
+
* @param onRotate Optional; called when a rotation is detected (a new inode,
|
|
43
|
+
* or the file shrank below the cursor) — the signal a NEW run
|
|
44
|
+
* has started, so the consumer can reset per-run state (rauf's
|
|
45
|
+
* event `seq` restarts at 0 each run).
|
|
46
|
+
* @param initialIno Optional; the inode the file had when the consumer last
|
|
47
|
+
* watched it (from the persisted mirror). Seeding it lets the
|
|
48
|
+
* FIRST poll on reattach detect a rotation that happened while
|
|
49
|
+
* no session was watching — the current file having a
|
|
50
|
+
* different inode fires `onRotate`, so a stale cursor from a
|
|
51
|
+
* previous run does not silently swallow the new run.
|
|
52
|
+
*/
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly filePath: string,
|
|
55
|
+
private readonly onRecord: (rec: RaufEvent) => void,
|
|
56
|
+
private readonly onError?: (reason: string) => void,
|
|
57
|
+
private readonly onRotate?: () => void,
|
|
58
|
+
initialIno?: number,
|
|
59
|
+
) {
|
|
60
|
+
this.ino = typeof initialIno === "number" ? initialIno : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read whatever is new since the last poll and dispatch each complete record.
|
|
65
|
+
* Idempotent and cheap when nothing changed. Safe to call before the file
|
|
66
|
+
* exists (no-op) and across a rotation (resets and re-reads the new file).
|
|
67
|
+
*/
|
|
68
|
+
poll(): void {
|
|
69
|
+
let size: number;
|
|
70
|
+
let ino: number;
|
|
71
|
+
try {
|
|
72
|
+
const st = statSync(this.filePath);
|
|
73
|
+
size = st.size;
|
|
74
|
+
ino = st.ino;
|
|
75
|
+
} catch {
|
|
76
|
+
// File gone (pre-launch, or mid-rotation between unlink and recreate).
|
|
77
|
+
// Reset so the next existing file is read from its start.
|
|
78
|
+
this.offset = 0;
|
|
79
|
+
this.ino = null;
|
|
80
|
+
this.partial = "";
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Rotation: a new inode, or the file shrank below our cursor (truncate /
|
|
85
|
+
// replace). Re-read from the top of whatever file is there now, reset the
|
|
86
|
+
// multibyte decoder, and signal the consumer so it can reset per-run state
|
|
87
|
+
// (rauf restarts `seq` at 0 each run — without this a stale cursor from a
|
|
88
|
+
// previous run would silently swallow the whole new run). This only fires
|
|
89
|
+
// on a genuine rotation: the first poll has ino === null, so neither branch
|
|
90
|
+
// trips on initial attach.
|
|
91
|
+
if ((this.ino !== null && ino !== this.ino) || size < this.offset) {
|
|
92
|
+
this.offset = 0;
|
|
93
|
+
this.partial = "";
|
|
94
|
+
this.decoder = new StringDecoder("utf8");
|
|
95
|
+
this.onRotate?.();
|
|
96
|
+
}
|
|
97
|
+
this.ino = ino;
|
|
98
|
+
|
|
99
|
+
if (size <= this.offset) return; // nothing new
|
|
100
|
+
|
|
101
|
+
let chunk: string;
|
|
102
|
+
let fd: number | null = null;
|
|
103
|
+
try {
|
|
104
|
+
fd = openSync(this.filePath, "r");
|
|
105
|
+
const length = size - this.offset;
|
|
106
|
+
const buf = Buffer.allocUnsafe(length);
|
|
107
|
+
const read = readSync(fd, buf, 0, length, this.offset);
|
|
108
|
+
// Decode through the StringDecoder so a multibyte char straddling this
|
|
109
|
+
// read's end is held back until its continuation bytes arrive next poll.
|
|
110
|
+
chunk = this.decoder.write(buf.subarray(0, read));
|
|
111
|
+
this.offset += read;
|
|
112
|
+
} catch {
|
|
113
|
+
return; // transient read failure; try again next poll
|
|
114
|
+
} finally {
|
|
115
|
+
if (fd !== null) closeSync(fd);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.partial += chunk;
|
|
119
|
+
const lines = this.partial.split("\n");
|
|
120
|
+
// The last element is an incomplete trailing line (empty when the chunk
|
|
121
|
+
// ended on a newline) — hold it until its newline arrives.
|
|
122
|
+
this.partial = lines.pop() ?? "";
|
|
123
|
+
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
const trimmed = line.trim();
|
|
126
|
+
if (!trimmed) continue;
|
|
127
|
+
let rec: RaufEvent;
|
|
128
|
+
try {
|
|
129
|
+
rec = JSON.parse(trimmed) as RaufEvent;
|
|
130
|
+
} catch {
|
|
131
|
+
this.onError?.(`skipped malformed NDJSON line (${trimmed.length} chars)`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (rec && typeof rec === "object") this.onRecord(rec);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/types.ts
|
|
2
|
+
// Regenerate with: python3 scripts/build-adapters.py
|
|
3
|
+
/**
|
|
4
|
+
* Shared types for the forge-loop-supervisor Pi extension.
|
|
5
|
+
*
|
|
6
|
+
* The extension launches a rauf loop in rauf's own detached/server-managed mode
|
|
7
|
+
* (`rauf loop run . --backlog <dir> --detached`), which returns immediately and
|
|
8
|
+
* leaves the loop running inside rauf's server daemon — so the loop outlives the
|
|
9
|
+
* Pi session by design. The supervisor then watches rauf's native, single-writer
|
|
10
|
+
* event stream (`<stateDir>/events.ndjson`), turning routine milestones into
|
|
11
|
+
* quiet deterministic progress and waking the session only on the events that
|
|
12
|
+
* need a human or the loop's own close-out.
|
|
13
|
+
*
|
|
14
|
+
* These types are host-agnostic on purpose: the core (classification, the NDJSON
|
|
15
|
+
* tailer, the task registry) takes a {@link SupervisorHost} so it can be unit
|
|
16
|
+
* tested with a fake host and a temp file, exactly as the vendored
|
|
17
|
+
* ask-user-question extension decouples its questionnaire from the live TUI.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A rauf persisted loop event. Only the fields this extension reads are typed;
|
|
21
|
+
* every record also carries `timestamp`, `projectPath`, and a per-run `seq`
|
|
22
|
+
* (rauf `packages/core/src/schemas.ts`). Unknown/extra fields are preserved. */
|
|
23
|
+
export interface RaufEvent {
|
|
24
|
+
type: string;
|
|
25
|
+
seq?: number;
|
|
26
|
+
timestamp?: string;
|
|
27
|
+
itemId?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
silentMs?: number;
|
|
31
|
+
completedCount?: number;
|
|
32
|
+
blockedCount?: number;
|
|
33
|
+
needsHumanCount?: number;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How the supervisor treats one event.
|
|
38
|
+
* - `progress`: a routine per-item milestone → one quiet deterministic line, NO
|
|
39
|
+
* model turn.
|
|
40
|
+
* - `exception`: something a human should see now (needs-human, block, stuck,
|
|
41
|
+
* review failure, error, cancellation) → notify AND wake the session.
|
|
42
|
+
* - `terminal`: the run finished → wake the session so the loop stage resumes
|
|
43
|
+
* its post-run steps.
|
|
44
|
+
* - `ignore`: firehose/interior events the supervisor does not surface. */
|
|
45
|
+
export type EventClass = "progress" | "exception" | "terminal" | "ignore";
|
|
46
|
+
|
|
47
|
+
/** Durable identity of one supervised loop, persisted so a later Pi session can
|
|
48
|
+
* reattach to a still-running (or already-finished) loop without relaunching. */
|
|
49
|
+
export interface SupervisorTask {
|
|
50
|
+
/** Backlog directory passed to `--backlog` (the forge {backlogDir}). */
|
|
51
|
+
backlogDir: string;
|
|
52
|
+
/** The runner state directory holding events.ndjson (default `<backlogDir>/.rauf`). */
|
|
53
|
+
stateDir: string;
|
|
54
|
+
/** Absolute path to the watched event file (`<stateDir>/events.ndjson`). */
|
|
55
|
+
eventsFile: string;
|
|
56
|
+
/** Inode of `eventsFile` when the mirror was last written. On reattach the
|
|
57
|
+
* tailer seeds its rotation check from this: a different inode means the file
|
|
58
|
+
* was rotated (a NEW run — rauf renames the old file to archive/ and recreates
|
|
59
|
+
* it) while no session was watching, so the per-run cursor must reset instead
|
|
60
|
+
* of stale-swallowing the new run. Absent on a fresh launch. */
|
|
61
|
+
eventsIno?: number;
|
|
62
|
+
/** ISO timestamp of the launch (for display + staleness reasoning). */
|
|
63
|
+
launchedAt: string;
|
|
64
|
+
/** Total backlog items at launch, when known — enables the `[N/M]` progress
|
|
65
|
+
* line. Absent when the launcher could not count the backlog. */
|
|
66
|
+
total?: number;
|
|
67
|
+
/** Highest event `seq` already surfaced — the dedup cursor across reattach. */
|
|
68
|
+
lastSeq: number;
|
|
69
|
+
/** Whether the supervisor has already surfaced a terminal event for this task. */
|
|
70
|
+
closed: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The narrow surface the pure core needs from its host (pi, or a test stub).
|
|
74
|
+
* Everything the supervisor does to the outside world goes through this, so the
|
|
75
|
+
* core carries no pi import and is unit-testable. */
|
|
76
|
+
export interface SupervisorHost {
|
|
77
|
+
/** A non-turn, user-visible line (routine progress + startup/info). */
|
|
78
|
+
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
79
|
+
/** Wake the active session with a message that triggers a model turn when
|
|
80
|
+
* idle (pi `sendMessage(..., { triggerTurn: true })`). Used ONLY for
|
|
81
|
+
* exception and terminal events — never routine progress. */
|
|
82
|
+
wake(message: string): void;
|
|
83
|
+
/** Persist the task record durably (pi `appendEntry` + a file mirror). */
|
|
84
|
+
persist(task: SupervisorTask): void;
|
|
85
|
+
/** Current time as ISO-8601, injectable for deterministic tests. */
|
|
86
|
+
now(): string;
|
|
87
|
+
}
|