@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic terminal-state classification for finished agent turns.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard has three user-facing post-run buckets:
|
|
5
|
+
* - needs_input → agent is waiting on the user
|
|
6
|
+
* - idle → in progress / worth revisiting
|
|
7
|
+
* - completed → done
|
|
8
|
+
*
|
|
9
|
+
* This module is pure (no model/network I/O). Runners may feed model JSON into
|
|
10
|
+
* parseAutoStateModelOutput(); otherwise heuristicAutoState() gives a safe fallback.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { deriveSummary } from "./derive.mjs";
|
|
14
|
+
import { detectNeedsInput, firstSentence, splitSentences, truncate } from "./heuristics.mjs";
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("./types.mjs").SemanticState} SemanticState */
|
|
17
|
+
/** @typedef {"needs_input"|"in_progress"|"done"} AutoStateKind */
|
|
18
|
+
/** @typedef {"model"|"heuristic"} AutoStateSource */
|
|
19
|
+
/** @typedef {"high"|"medium"|"low"} AutoStateConfidence */
|
|
20
|
+
|
|
21
|
+
const AUTO_STATE_KINDS = new Set(["needs_input", "in_progress", "done"]);
|
|
22
|
+
const CONFIDENCES = new Set(["high", "medium", "low"]);
|
|
23
|
+
const DEFAULT_AUTO_STATE_MODEL = "gpt-4o";
|
|
24
|
+
|
|
25
|
+
/** @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] */
|
|
26
|
+
export function autoStateEnabled(env = process.env) {
|
|
27
|
+
const raw = env.AGENT_BOARD_AUTO_STATE ?? env.AGENT_VIEW_AUTO_STATE;
|
|
28
|
+
return !isOff(raw);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] */
|
|
32
|
+
export function autoStateModel(env = process.env) {
|
|
33
|
+
const configured = env.AGENT_BOARD_AUTO_STATE_MODEL ?? env.AGENT_VIEW_AUTO_STATE_MODEL;
|
|
34
|
+
if (isOff(configured)) return null;
|
|
35
|
+
return configured || DEFAULT_AUTO_STATE_MODEL;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {string|undefined} value */
|
|
39
|
+
function isOff(value) {
|
|
40
|
+
return typeof value === "string" && /^(0|false|off|no)$/i.test(value.trim());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether automatic `done` classification is disabled.
|
|
45
|
+
* Default (env unset): disabled — completed is only ever set by the user.
|
|
46
|
+
* Set AGENT_BOARD_AUTO_STATE_NO_DONE to 0/false/off/no to restore auto-done.
|
|
47
|
+
* @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env]
|
|
48
|
+
*/
|
|
49
|
+
export function autoStateDoneDisabled(env = process.env) {
|
|
50
|
+
const raw = env.AGENT_BOARD_AUTO_STATE_NO_DONE;
|
|
51
|
+
if (typeof raw !== "string" || !raw.trim()) return true;
|
|
52
|
+
return !isOff(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {AutoStateKind} kind @returns {SemanticState} */
|
|
56
|
+
export function semanticStateForAutoKind(kind) {
|
|
57
|
+
switch (kind) {
|
|
58
|
+
case "needs_input":
|
|
59
|
+
return "needs_input";
|
|
60
|
+
case "done":
|
|
61
|
+
return "completed";
|
|
62
|
+
default:
|
|
63
|
+
return "idle";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prompt used by runners for the cheap classifier pass.
|
|
69
|
+
* @param {string} latestAssistantText
|
|
70
|
+
* @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env]
|
|
71
|
+
*/
|
|
72
|
+
export function buildAutoStatePrompt(latestAssistantText, env = process.env) {
|
|
73
|
+
const text = truncate(String(latestAssistantText || "").trim(), 6000);
|
|
74
|
+
const doneLine = autoStateDoneDisabled(env)
|
|
75
|
+
? ""
|
|
76
|
+
: "- done: the requested work is complete, final answer given, no user input required.\n";
|
|
77
|
+
const doneNote = autoStateDoneDisabled(env)
|
|
78
|
+
? "The user marks completed manually in the dashboard, so completion signals (done/completed/finished) must be classified as in_progress.\n"
|
|
79
|
+
: "";
|
|
80
|
+
const states = autoStateDoneDisabled(env) ? "needs_input|in_progress" : "needs_input|in_progress|done";
|
|
81
|
+
return `Classify the LAST assistant response for a coding-agent dashboard.\n\nChoose exactly one state:\n- needs_input: the assistant asks the user for a decision, clarification, approval, credentials, or is blocked waiting for the user.\n- in_progress: work is partial, next steps remain, verification is pending/failed, or the assistant says it will continue later.\n${doneLine}${doneNote}\nReturn ONLY minified JSON with this shape:\n{"state":"${states}","confidence":"high|medium|low","reason":"short reason <=18 words","question":"user-facing question or null"}\n\nLast assistant response:\n${text}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse and normalize model JSON. Returns null if the response is unusable.
|
|
86
|
+
* @param {string} raw
|
|
87
|
+
* @param {{ latestAssistantText?: string, now?: number, lastAgentActivityAt?: number|null, env?: NodeJS.ProcessEnv|Record<string,string|undefined> }} [opts]
|
|
88
|
+
*/
|
|
89
|
+
export function parseAutoStateModelOutput(raw, opts = {}) {
|
|
90
|
+
const obj = extractJsonObject(raw);
|
|
91
|
+
if (!obj) return null;
|
|
92
|
+
let kind = normalizeKind(obj.state ?? obj.kind ?? obj.status);
|
|
93
|
+
if (!kind) return null;
|
|
94
|
+
let downgraded = false;
|
|
95
|
+
if (kind === "done" && autoStateDoneDisabled(opts.env ?? process.env)) {
|
|
96
|
+
kind = "in_progress";
|
|
97
|
+
downgraded = true;
|
|
98
|
+
}
|
|
99
|
+
const confidence = normalizeConfidence(obj.confidence);
|
|
100
|
+
const latest = opts.latestAssistantText ?? "";
|
|
101
|
+
const nb = detectNeedsInput(latest);
|
|
102
|
+
const question = kind === "needs_input" ? cleanQuestion(obj.question) || nb.question || latestQuestion(latest) : null;
|
|
103
|
+
return makeClassification(kind, {
|
|
104
|
+
source: "model",
|
|
105
|
+
confidence,
|
|
106
|
+
reason: downgraded
|
|
107
|
+
? "Model reported done but auto-done is disabled"
|
|
108
|
+
: cleanReason(obj.reason) || defaultReason(kind),
|
|
109
|
+
question,
|
|
110
|
+
now: opts.now,
|
|
111
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
112
|
+
latestAssistantText: latest,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Conservative fallback classifier. It should be useful, but never clever enough to
|
|
118
|
+
* overrule strong question/error signals.
|
|
119
|
+
* @param {string} latestAssistantText
|
|
120
|
+
* @param {{ now?: number, lastAgentActivityAt?: number|null, env?: NodeJS.ProcessEnv|Record<string,string|undefined> }} [opts]
|
|
121
|
+
*/
|
|
122
|
+
export function heuristicAutoState(latestAssistantText, opts = {}) {
|
|
123
|
+
const text = String(latestAssistantText || "").trim();
|
|
124
|
+
const nb = detectNeedsInput(text);
|
|
125
|
+
if (nb.needsInput) {
|
|
126
|
+
return makeClassification("needs_input", {
|
|
127
|
+
source: "heuristic",
|
|
128
|
+
confidence: "high",
|
|
129
|
+
reason: "Assistant asked for user input",
|
|
130
|
+
question: nb.question,
|
|
131
|
+
now: opts.now,
|
|
132
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
133
|
+
latestAssistantText: text,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lower = text.toLowerCase();
|
|
138
|
+
const pending = hasPendingSignal(lower);
|
|
139
|
+
const done = hasDoneSignal(lower);
|
|
140
|
+
const env = opts.env ?? process.env;
|
|
141
|
+
if (done && !pending && !autoStateDoneDisabled(env)) {
|
|
142
|
+
return makeClassification("done", {
|
|
143
|
+
source: "heuristic",
|
|
144
|
+
confidence: hasStrongDoneSignal(lower) ? "high" : "medium",
|
|
145
|
+
reason: "Assistant reported the work is complete",
|
|
146
|
+
now: opts.now,
|
|
147
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
148
|
+
latestAssistantText: text,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (done && !pending) {
|
|
152
|
+
// Auto-done disabled: completion signals stay out of the completed bucket;
|
|
153
|
+
// the user marks completed manually from the dashboard.
|
|
154
|
+
return makeClassification("in_progress", {
|
|
155
|
+
source: "heuristic",
|
|
156
|
+
confidence: hasStrongDoneSignal(lower) ? "medium" : "low",
|
|
157
|
+
reason: "Assistant reported completion but auto-done is disabled",
|
|
158
|
+
now: opts.now,
|
|
159
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
160
|
+
latestAssistantText: text,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (pending) {
|
|
164
|
+
return makeClassification("in_progress", {
|
|
165
|
+
source: "heuristic",
|
|
166
|
+
confidence: "medium",
|
|
167
|
+
reason: "Assistant mentioned remaining work or pending verification",
|
|
168
|
+
now: opts.now,
|
|
169
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
170
|
+
latestAssistantText: text,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return makeClassification("in_progress", {
|
|
174
|
+
source: "heuristic",
|
|
175
|
+
confidence: "low",
|
|
176
|
+
reason: "No clear completion or input signal",
|
|
177
|
+
now: opts.now,
|
|
178
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
179
|
+
latestAssistantText: text,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pick a model classification when valid, otherwise fall back to heuristics.
|
|
185
|
+
* @param {string} modelOutput
|
|
186
|
+
* @param {string} latestAssistantText
|
|
187
|
+
* @param {{ now?: number, lastAgentActivityAt?: number|null }} [opts]
|
|
188
|
+
*/
|
|
189
|
+
export function autoStateFromModelOrHeuristic(modelOutput, latestAssistantText, opts = {}) {
|
|
190
|
+
return parseAutoStateModelOutput(modelOutput, { latestAssistantText, ...opts }) ?? heuristicAutoState(latestAssistantText, opts);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether a row/status carries a manual verdict (issue #145): the user placed a
|
|
195
|
+
* terminal judgment ("completed" or "holding") that no automated writer may
|
|
196
|
+
* overwrite. mark_completed and mark_holding both clear autoState on
|
|
197
|
+
* state.json and status.json, so `autoState == null` combined with a verdict
|
|
198
|
+
* state is the persisted signal. Wide on purpose (spec D15): no call site
|
|
199
|
+
* needs a completed-only predicate.
|
|
200
|
+
* @param {{ semanticState?: string, autoState?: unknown|null }|null|undefined} state
|
|
201
|
+
*/
|
|
202
|
+
export function isManualVerdict(state) {
|
|
203
|
+
return Boolean(state && (state.semanticState === "completed" || state.semanticState === "holding") && state.autoState == null);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Mutate a RunStatus with a classification. Returns true if state/metadata changed.
|
|
208
|
+
* @param {import("./types.mjs").RunStatus} status
|
|
209
|
+
* @param {import("./types.mjs").AutoStateClassification} classification
|
|
210
|
+
* @param {number} [now]
|
|
211
|
+
*/
|
|
212
|
+
export function applyAutoStateToStatus(status, classification, now = Date.now()) {
|
|
213
|
+
if (!classification || status.processState === "alive") return false;
|
|
214
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
215
|
+
// Manual verdicts are user judgments and must never be overwritten.
|
|
216
|
+
if (isManualVerdict(status)) return false;
|
|
217
|
+
const nextState = semanticStateForAutoKind(classification.kind);
|
|
218
|
+
const before = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
|
|
219
|
+
status.semanticState = nextState;
|
|
220
|
+
status.autoState = { ...classification, semanticState: nextState, classifiedAt: now };
|
|
221
|
+
if (nextState === "needs_input") {
|
|
222
|
+
status.question = classification.question || detectNeedsInput(status.latestAssistantPreview).question || latestQuestion(status.latestAssistantPreview);
|
|
223
|
+
} else {
|
|
224
|
+
status.question = null;
|
|
225
|
+
}
|
|
226
|
+
status.summary = deriveSummary(status);
|
|
227
|
+
const after = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
|
|
228
|
+
return before !== after;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Mutate a ViewState with a classification. Returns true if state/metadata changed.
|
|
233
|
+
* @param {import("./types.mjs").ViewState} state
|
|
234
|
+
* @param {import("./types.mjs").AutoStateClassification} classification
|
|
235
|
+
* @param {number} [now]
|
|
236
|
+
*/
|
|
237
|
+
export function applyAutoStateToViewState(state, classification, now = Date.now()) {
|
|
238
|
+
if (!classification || state.processState === "alive") return false;
|
|
239
|
+
if (state.semanticState === "failed" || state.semanticState === "stopped") return false;
|
|
240
|
+
// Manual verdicts are user judgments and must never be overwritten.
|
|
241
|
+
if (isManualVerdict(state)) return false;
|
|
242
|
+
const nextState = semanticStateForAutoKind(classification.kind);
|
|
243
|
+
const before = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
|
|
244
|
+
state.semanticState = nextState;
|
|
245
|
+
state.needsInput = nextState === "needs_input";
|
|
246
|
+
state.hasError = nextState === "failed";
|
|
247
|
+
state.autoState = { ...classification, semanticState: nextState, classifiedAt: now };
|
|
248
|
+
if (nextState === "needs_input") {
|
|
249
|
+
state.question = classification.question || detectNeedsInput(state.latestAssistantPreview).question || latestQuestion(state.latestAssistantPreview);
|
|
250
|
+
} else {
|
|
251
|
+
state.question = null;
|
|
252
|
+
}
|
|
253
|
+
state.error = nextState === "failed" ? state.error : null;
|
|
254
|
+
state.updatedAt = now;
|
|
255
|
+
state.summary = deriveSummary({
|
|
256
|
+
processState: state.processState,
|
|
257
|
+
semanticState: state.semanticState,
|
|
258
|
+
currentTool: state.latestTool,
|
|
259
|
+
question: state.question,
|
|
260
|
+
error: state.error,
|
|
261
|
+
latestAssistantPreview: state.latestAssistantPreview,
|
|
262
|
+
});
|
|
263
|
+
const after = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
|
|
264
|
+
return before !== after;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {AutoStateKind} kind
|
|
269
|
+
* @param {{ source: AutoStateSource, confidence?: AutoStateConfidence, reason?: string, question?: string|null, now?: number, lastAgentActivityAt?: number|null, latestAssistantText?: string }} opts
|
|
270
|
+
* @returns {import("./types.mjs").AutoStateClassification}
|
|
271
|
+
*/
|
|
272
|
+
function makeClassification(kind, opts) {
|
|
273
|
+
const now = opts.now ?? Date.now();
|
|
274
|
+
const semanticState = semanticStateForAutoKind(kind);
|
|
275
|
+
return {
|
|
276
|
+
version: 1,
|
|
277
|
+
kind,
|
|
278
|
+
semanticState,
|
|
279
|
+
confidence: opts.confidence ?? "low",
|
|
280
|
+
source: opts.source,
|
|
281
|
+
reason: truncate(String(opts.reason || defaultReason(kind)).replace(/\s+/g, " ").trim(), 120),
|
|
282
|
+
question: opts.question ? truncate(String(opts.question).replace(/\s+/g, " ").trim(), 240) : null,
|
|
283
|
+
classifiedAt: now,
|
|
284
|
+
lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
|
|
285
|
+
textHash: textHash(opts.latestAssistantText ?? ""),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @param {string} raw */
|
|
290
|
+
function extractJsonObject(raw) {
|
|
291
|
+
const text = String(raw || "").trim();
|
|
292
|
+
if (!text) return null;
|
|
293
|
+
const candidates = [text];
|
|
294
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
295
|
+
if (fenced) candidates.unshift(fenced[1].trim());
|
|
296
|
+
const first = text.indexOf("{");
|
|
297
|
+
const last = text.lastIndexOf("}");
|
|
298
|
+
if (first >= 0 && last > first) candidates.unshift(text.slice(first, last + 1));
|
|
299
|
+
for (const candidate of candidates) {
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(candidate);
|
|
302
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
303
|
+
} catch {
|
|
304
|
+
/* try next */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @param {any} value @returns {AutoStateKind|null} */
|
|
311
|
+
function normalizeKind(value) {
|
|
312
|
+
const s = String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
313
|
+
if (AUTO_STATE_KINDS.has(s)) return /** @type {AutoStateKind} */ (s);
|
|
314
|
+
if (["needsinput", "input", "input_required", "requires_input", "awaiting_input", "blocked", "question"].includes(s)) return "needs_input";
|
|
315
|
+
if (["progress", "ongoing", "continue", "continuing", "partial", "not_done", "todo"].includes(s)) return "in_progress";
|
|
316
|
+
if (["complete", "completed", "finished", "finish", "success", "resolved"].includes(s)) return "done";
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** @param {any} value @returns {AutoStateConfidence} */
|
|
321
|
+
function normalizeConfidence(value) {
|
|
322
|
+
const s = String(value ?? "medium").toLowerCase().trim();
|
|
323
|
+
return CONFIDENCES.has(s) ? /** @type {AutoStateConfidence} */ (s) : "medium";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** @param {any} value */
|
|
327
|
+
function cleanReason(value) {
|
|
328
|
+
return String(value ?? "").replace(/\s+/g, " ").trim();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @param {any} value */
|
|
332
|
+
function cleanQuestion(value) {
|
|
333
|
+
const s = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
334
|
+
if (!s || /^null$/i.test(s) || /^none$/i.test(s)) return null;
|
|
335
|
+
return s;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @param {AutoStateKind} kind */
|
|
339
|
+
function defaultReason(kind) {
|
|
340
|
+
switch (kind) {
|
|
341
|
+
case "needs_input":
|
|
342
|
+
return "Assistant is waiting for user input";
|
|
343
|
+
case "done":
|
|
344
|
+
return "Assistant reported completion";
|
|
345
|
+
default:
|
|
346
|
+
return "Assistant appears to have more work remaining";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** @param {string} text */
|
|
351
|
+
function latestQuestion(text) {
|
|
352
|
+
const sentences = splitSentences(text);
|
|
353
|
+
return [...sentences].reverse().find((s) => /\?\s*$/.test(s)) ?? null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @param {string} lower */
|
|
357
|
+
function hasStrongDoneSignal(lower) {
|
|
358
|
+
return /(^|\b)(done|completed|complete|finished|resolved|all set|implemented|shipped)(\b|[.!])/i.test(lower) || /tests? (pass|passed|passing)/i.test(lower);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** @param {string} lower */
|
|
362
|
+
function hasDoneSignal(lower) {
|
|
363
|
+
return hasStrongDoneSignal(lower)
|
|
364
|
+
|| /\b(final answer|summary:|i'?ve (fixed|updated|added|implemented)|successfully|ready to go)\b/i.test(lower)
|
|
365
|
+
|| firstSentence(lower).length > 0 && /^done[.!]/i.test(firstSentence(lower));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** @param {string} lower */
|
|
369
|
+
function hasPendingSignal(lower) {
|
|
370
|
+
return /\b(todo|next steps?|remaining|still need|need to|not yet|pending|in progress|continue|will continue|i'?ll continue|i will|follow up|not run|couldn'?t run|unable to run|blocked|waiting for)\b/i.test(lower)
|
|
371
|
+
|| /\btests? (fail|failed|failing|pending|not run|weren'?t run|couldn'?t be run)\b/i.test(lower);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** @param {string} text */
|
|
375
|
+
function textHash(text) {
|
|
376
|
+
return createHash("sha1").update(String(text || "")).digest("hex").slice(0, 12);
|
|
377
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-workflow run detection for warm-host eviction (issue #230).
|
|
3
|
+
*
|
|
4
|
+
* A warm host whose session owns a `running` pi-workflow run must not be swept:
|
|
5
|
+
* eviction revokes the host, the child pi gets SIGTERM, and the in-flight agent
|
|
6
|
+
* step turns into a `skipped/interrupted` event — the run ends `paused` while the
|
|
7
|
+
* user is never told. The sweeper cannot see this from row state because
|
|
8
|
+
* `workflow({ background: true })` returns `started` before the step finishes, so
|
|
9
|
+
* the row falls back to `idle` and looks evictable.
|
|
10
|
+
*
|
|
11
|
+
* This module is the decision core plus its thin IO shell: parse a run head
|
|
12
|
+
* record, match it to a row's session file, classify the protection outcome, and
|
|
13
|
+
* enumerate/probe the on-disk run store (`scanBackgroundRuns`/`probeBackgroundRun`).
|
|
14
|
+
* Design: docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Timestamp tolerance for FS mtime granularity and cross-process clock skew
|
|
21
|
+
* when comparing a run's `updatedAt` against the host start time (spec D1). */
|
|
22
|
+
export const CLOCK_EPSILON_MS = 2000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} RunHead
|
|
26
|
+
* @property {string} runId
|
|
27
|
+
* @property {string} status
|
|
28
|
+
* @property {string|null} parentSessionFile legacy records carry none
|
|
29
|
+
* @property {number|null} startedAtMs
|
|
30
|
+
* @property {number|null} updatedAtMs
|
|
31
|
+
* @property {boolean} hasCheckpoint
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** @typedef {"protecting"|"paused"|"irrelevant"} RunVerdict */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Root directory of pi-dynamic-workflows: `$AGENT_BOARD_WORKFLOW_HOME` (explicit
|
|
38
|
+
* override) > `$PI_CODING_AGENT_DIR/workflows` > `<home>/.pi/workflows`.
|
|
39
|
+
* Both `env` and `home` are injectable so callers and tests never depend on the
|
|
40
|
+
* ambient environment or the real homedir.
|
|
41
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [o]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveWorkflowHome({ env = process.env, home = os.homedir() } = {}) {
|
|
45
|
+
const expand = (p) => (p.startsWith("~") ? home + p.slice(1) : p);
|
|
46
|
+
const explicit = env.AGENT_BOARD_WORKFLOW_HOME;
|
|
47
|
+
if (typeof explicit === "string" && explicit.length > 0) return expand(explicit);
|
|
48
|
+
const agentDir = env.PI_CODING_AGENT_DIR;
|
|
49
|
+
const base = typeof agentDir === "string" && agentDir.length > 0 ? expand(agentDir) : joinPath(home, ".pi");
|
|
50
|
+
return joinPath(base, "workflows");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse a run head record into the fields the protection decision needs, or
|
|
55
|
+
* `null` when it is not a workflow run. A v2 record nests the run fields under
|
|
56
|
+
* `index` but keeps `summary` (and therefore the checkpoint) as a sibling at the
|
|
57
|
+
* record root; legacy flat records carry `checkpoint` at the root themselves and
|
|
58
|
+
* can never match a row because they have no `parentSessionFile` (spec non-goal:
|
|
59
|
+
* no legacy session matching).
|
|
60
|
+
* Never throws: the input is external, unversioned JSON.
|
|
61
|
+
* @param {unknown} raw parsed JSON
|
|
62
|
+
* @returns {RunHead|null}
|
|
63
|
+
*/
|
|
64
|
+
export function parseRunHead(raw) {
|
|
65
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
66
|
+
const record = /** @type {Record<string, any>} */ (raw);
|
|
67
|
+
const source = record.format === "pi-workflow-run-v2" ? record.index : record;
|
|
68
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) return null;
|
|
69
|
+
if (typeof source.runId !== "string") return null;
|
|
70
|
+
return {
|
|
71
|
+
runId: source.runId,
|
|
72
|
+
status: typeof source.status === "string" ? source.status : "",
|
|
73
|
+
parentSessionFile: typeof source.parentSessionFile === "string" ? source.parentSessionFile : null,
|
|
74
|
+
startedAtMs: toMs(source.startedAt),
|
|
75
|
+
updatedAtMs: toMs(source.updatedAt),
|
|
76
|
+
hasCheckpoint: Boolean(record.summary?.checkpoint ?? source.checkpoint),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether two spellings point at the same session file. win32 compares
|
|
82
|
+
* case-insensitively (the row meta and the workflow record disagree on drive
|
|
83
|
+
* letter and segment casing; they also mix `\` and `/`); POSIX stays
|
|
84
|
+
* case-sensitive. `platform` is injectable because CI only runs Linux, so the
|
|
85
|
+
* win32 branch would otherwise be untested (spec A5).
|
|
86
|
+
* @param {string} a
|
|
87
|
+
* @param {string} b
|
|
88
|
+
* @param {{ platform?: string }} [o]
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
export function isSameSessionFile(a, b, { platform = process.platform } = {}) {
|
|
92
|
+
if (typeof a !== "string" || typeof b !== "string" || a.length === 0 || b.length === 0) return false;
|
|
93
|
+
const impl = platform === "win32" ? path.win32 : path.posix;
|
|
94
|
+
const na = impl.resolve(a);
|
|
95
|
+
const nb = impl.resolve(b);
|
|
96
|
+
return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Protection verdict for one run against one row (spec D1/D2).
|
|
101
|
+
*
|
|
102
|
+
* Order matters: a run belonging to another session is always irrelevant (a
|
|
103
|
+
* running or paused run of a sibling row must never keep this host alive);
|
|
104
|
+
* `paused` is reported separately because a paused run needs no host at all and
|
|
105
|
+
* only drives the "run was interrupted" notice; a running run protects only when
|
|
106
|
+
* it was updated after the host started, so a stale `running` record left behind
|
|
107
|
+
* by a previous host incarnation cannot exempt the row forever.
|
|
108
|
+
* @param {RunHead|null|undefined} run
|
|
109
|
+
* @param {{ sessionFile: string|null|undefined, hostStartedAt: number|null|undefined }} o
|
|
110
|
+
* @returns {RunVerdict}
|
|
111
|
+
*/
|
|
112
|
+
export function classifyRunForRow(run, { sessionFile, hostStartedAt }) {
|
|
113
|
+
if (!run || !run.parentSessionFile) return "irrelevant";
|
|
114
|
+
if (!isSameSessionFile(run.parentSessionFile, sessionFile)) return "irrelevant";
|
|
115
|
+
if (run.status === "paused") return "paused";
|
|
116
|
+
if (run.status !== "running") return "irrelevant";
|
|
117
|
+
if (hostStartedAt == null || run.updatedAtMs == null) return "protecting";
|
|
118
|
+
if (run.updatedAtMs >= hostStartedAt - CLOCK_EPSILON_MS) return "protecting";
|
|
119
|
+
return "irrelevant";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Strongest verdict across a scanned head list: protecting > paused >
|
|
124
|
+
* irrelevant. Returns the run that produced it so callers can name its runId in
|
|
125
|
+
* a diagnostic or a row notice; `run` is null for "irrelevant".
|
|
126
|
+
* @param {Array<RunHead|null|undefined>} runs
|
|
127
|
+
* @param {{ sessionFile: string|null|undefined, hostStartedAt: number|null|undefined }} o
|
|
128
|
+
* @returns {{ state: RunVerdict, run: RunHead|null }}
|
|
129
|
+
*/
|
|
130
|
+
export function classifyRunsForRow(runs, o) {
|
|
131
|
+
let state = /** @type {RunVerdict} */ ("irrelevant");
|
|
132
|
+
let match = null;
|
|
133
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
134
|
+
const verdict = classifyRunForRow(run, o);
|
|
135
|
+
if (verdict === "protecting") return { state: "protecting", run };
|
|
136
|
+
if (verdict === "paused" && state === "irrelevant") {
|
|
137
|
+
state = "paused";
|
|
138
|
+
match = run;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { state, run: match };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @typedef {RunHead & { path: string, mtimeMs: number }} RunHeadWithPath
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Enumerate every run head under `<workflowHome>/projects/<project>/runs/`.
|
|
150
|
+
* Only `*.json` regular files are candidates (upstream keeps `.bak`,
|
|
151
|
+
* `.events.jsonl`, `.log` and `.result-*` siblings next to the head), and
|
|
152
|
+
* `minMtimeMs` drops heads too old to belong to a live host — a prefilter only,
|
|
153
|
+
* the protection verdict still comes from `classifyRunsForRow`.
|
|
154
|
+
*
|
|
155
|
+
* Failure policy (spec D6): an absent store is normal and yields `ok:true`; any
|
|
156
|
+
* incomplete scan yields `ok:false` so the caller logs a `warm_prune_probe_failed`
|
|
157
|
+
* diagnostic instead of silently guessing. An incomplete scan is any unreadable
|
|
158
|
+
* directory or head file, including corrupt JSON — the eviction outcome is the
|
|
159
|
+
* same either way (a run that cannot be read protects nothing), but only
|
|
160
|
+
* `ok:false` marks the coverage as incomplete (it does not disable protection).
|
|
161
|
+
* Heads that were read successfully are still returned and still participate in
|
|
162
|
+
* the protection classification; a run that could not be read simply has no
|
|
163
|
+
* evidence and protects nothing. A head file that parses but is not a run head
|
|
164
|
+
* (`parseRunHead` → null) is not an error: it is simply not a candidate.
|
|
165
|
+
* The store root is stat-ed before the child paths are enumerated because on
|
|
166
|
+
* win32 a child path under a regular file reports `ENOENT` exactly like a
|
|
167
|
+
* missing directory does: a missing `workflowHome` is "no store yet", a
|
|
168
|
+
* `workflowHome` that is not a directory is a read failure.
|
|
169
|
+
* @param {{ workflowHome: string, minMtimeMs?: number }} o
|
|
170
|
+
* @returns {{ ok: boolean, heads: Array<RunHeadWithPath>, error: string|null }}
|
|
171
|
+
*/
|
|
172
|
+
export function scanBackgroundRuns({ workflowHome, minMtimeMs = 0 }) {
|
|
173
|
+
const heads = [];
|
|
174
|
+
let error = null;
|
|
175
|
+
const noteError = (message) => {
|
|
176
|
+
error ??= message;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let projects;
|
|
180
|
+
try {
|
|
181
|
+
if (!statSync(workflowHome).isDirectory()) {
|
|
182
|
+
return { ok: false, heads: [], error: `workflow home is not a directory: ${workflowHome}` };
|
|
183
|
+
}
|
|
184
|
+
projects = readdirSync(joinPath(workflowHome, "projects"), { withFileTypes: true });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (e?.code === "ENOENT") return { ok: true, heads: [], error: null };
|
|
187
|
+
return { ok: false, heads: [], error: errorMessage(e) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const project of projects) {
|
|
191
|
+
if (!project.isDirectory()) continue;
|
|
192
|
+
const runsDir = joinPath(workflowHome, "projects", project.name, "runs");
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = readdirSync(runsDir, { withFileTypes: true });
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e?.code !== "ENOENT") noteError(`${runsDir}: ${errorMessage(e)}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
202
|
+
const filePath = joinPath(runsDir, entry.name);
|
|
203
|
+
try {
|
|
204
|
+
const mtimeMs = statSync(filePath).mtimeMs;
|
|
205
|
+
if (mtimeMs < minMtimeMs) continue;
|
|
206
|
+
const head = parseRunHead(JSON.parse(readFileSync(filePath, "utf8")));
|
|
207
|
+
if (head) heads.push({ ...head, path: filePath, mtimeMs });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
noteError(`${filePath}: ${errorMessage(e)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { ok: error === null, heads, error };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Fresh protection probe for one row: the scan prefiltered to runs that could
|
|
219
|
+
* have been updated by this host incarnation, classified against the row's
|
|
220
|
+
* session file. Each field answers one question and is filled independently
|
|
221
|
+
* (design §5.1: a running/paused split): `running` is the run that must keep the
|
|
222
|
+
* host alive (spec D1), `paused` is the run whose interruption the reconcile
|
|
223
|
+
* pass announces (spec D2). They coexist because one session may hold an older
|
|
224
|
+
* paused run while a new one is running, and each consumer reads one field.
|
|
225
|
+
* `ok:false` only means the store could not be read (spec D6).
|
|
226
|
+
* @param {{ sessionFile: string|null, hostStartedAt: number|null, workflowHome: string }} o
|
|
227
|
+
* @returns {{ ok: boolean, running: RunHeadWithPath|null, paused: RunHeadWithPath|null, error: string|null }}
|
|
228
|
+
*/
|
|
229
|
+
export function probeBackgroundRun({ sessionFile, hostStartedAt, workflowHome }) {
|
|
230
|
+
const minMtimeMs = hostStartedAt == null ? 0 : hostStartedAt - CLOCK_EPSILON_MS;
|
|
231
|
+
const scan = scanBackgroundRuns({ workflowHome, minMtimeMs });
|
|
232
|
+
if (!scan.ok) return { ok: false, running: null, paused: null, error: scan.error };
|
|
233
|
+
const verdict = classifyRunsForRow(scan.heads, { sessionFile, hostStartedAt });
|
|
234
|
+
const running = verdict.state === "protecting" ? verdict.run : null;
|
|
235
|
+
// Newest paused run wins, because it is the one most likely to be the run the
|
|
236
|
+
// just-ended host left behind; `path` only breaks ties deterministically.
|
|
237
|
+
const paused = scan.heads
|
|
238
|
+
.filter((head) => classifyRunForRow(head, { sessionFile, hostStartedAt }) === "paused")
|
|
239
|
+
.sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0) || a.path.localeCompare(b.path))[0] ?? null;
|
|
240
|
+
return { ok: true, running, paused, error: scan.error };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Join segments with the separator style of the base path. The ambient
|
|
245
|
+
* `path.join` uses the host separator, which would turn POSIX-style injected
|
|
246
|
+
* inputs (tests, Linux CI) into backslash paths when the suite runs on Windows,
|
|
247
|
+
* so the style is read from the base instead of the platform.
|
|
248
|
+
* @param {string} base
|
|
249
|
+
* @param {...string} parts
|
|
250
|
+
* @returns {string}
|
|
251
|
+
*/
|
|
252
|
+
function joinPath(base, ...parts) {
|
|
253
|
+
const impl = /^[A-Za-z]:/.test(base) || base.includes("\\") ? path.win32 : path.posix;
|
|
254
|
+
return impl.join(base, ...parts);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {unknown} e
|
|
259
|
+
* @returns {string}
|
|
260
|
+
*/
|
|
261
|
+
function errorMessage(e) {
|
|
262
|
+
return e instanceof Error ? e.message : String(e);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {unknown} value
|
|
267
|
+
* @returns {number|null}
|
|
268
|
+
*/
|
|
269
|
+
function toMs(value) {
|
|
270
|
+
if (typeof value !== "string") return null;
|
|
271
|
+
const ms = Date.parse(value);
|
|
272
|
+
return Number.isFinite(ms) ? ms : null;
|
|
273
|
+
}
|