@llblab/pi-kit 0.21.0 → 0.21.2
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 +12 -0
- package/README.md +3 -3
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-state-flow/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +17 -6
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +4 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +29 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +33 -7
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +10 -2
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +4 -4
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +15 -6
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +24 -1
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +31 -6
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +3 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +6 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +5 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +8 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +63 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +4 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +14 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +5 -0
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +7 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +13 -2
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +19 -0
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +9 -0
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +75 -16
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +18 -0
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +7 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@llblab/pi-kit` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.21.2 - 2026-09-23
|
|
6
|
+
|
|
7
|
+
- `Backup Push Reliability`: Advances the exact State Flow pin to `0.18.1`. Backup replication permits one in-flight push per repository; overlapping attempts are skipped until a later accepted turn, while session shutdown waits for the active process to close. Canonical state acceptance remains independent of Git replication.
|
|
8
|
+
- `Failure Diagnostics`: Push failures warn once per streak and retain redacted Git detail in the local diagnostic log even when general logging is off; a successful push resets warning suppression. If logging fails, the warning includes the available detail instead.
|
|
9
|
+
- `Prompt-Prefix Evidence`: The State Flow v3 synthetic benchmark now measures serialized per-inference context and within-run shared prefixes, including isolated resume probes. It does not change model projection or claim provider cache gains.
|
|
10
|
+
|
|
11
|
+
## 0.21.1 - 2026-09-23
|
|
12
|
+
|
|
13
|
+
- `Queue Transition Reliability`: Advances the exact Telegram pin to `0.51.1`. Busy `/next` now reports the interrupted turn and exact selected queued prompt in order, preserves one reply-header owner through agent start, keeps rapid ordinary messages separate, and lets `/abort` or `/stop` cancel stale notices without allowing notice failures to block dispatch.
|
|
14
|
+
- `Follower Heartbeat Stability`: Uses a dedicated eight-second follower heartbeat response deadline aligned with leader stale-liveness policy, preventing ordinary multi-second Pi/TUI event-loop stalls from destroying a healthy socket, producing leader `EPIPE` noise, or cycling registration status.
|
|
15
|
+
- `Package Cohort`: Keeps every other bundled package at its current exact version. Package membership, resource paths, load order, Pi minimum, and bundled Skill ownership remain unchanged.
|
|
16
|
+
|
|
5
17
|
## 0.21.0 - 2026-09-23
|
|
6
18
|
|
|
7
19
|
- `Independent Scope Revisions`: Advances the exact State Flow pin to `0.18.0`. Global, CWD, and Session now persist independent semantic revision counters, Effective displays the truthful `G#/C#/S#` vector, and Session exclusively owns response state while Global and CWD retain only their structural placeholders.
|
package/README.md
CHANGED
|
@@ -14,15 +14,15 @@ Package links lead to the owning repositories for usage, documentation, issues,
|
|
|
14
14
|
| [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.1.1` | Isolated nested Pi TUI with explicitly selected extensions |
|
|
15
15
|
| [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.10.0` | Compact Codex/Spark subscription-limit and Business credit-usage status |
|
|
16
16
|
| [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.8.1` | Visible continuation scheduling and bounded worker Skills |
|
|
17
|
-
| [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.18.
|
|
18
|
-
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.
|
|
17
|
+
| [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.18.1` | Incremental scoped context/memory compiler with independent scope revisions, canonical file persistence, bounded and diagnosable Git backup replication, native working context within each run, and targeted historical reads |
|
|
18
|
+
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.1` | Telegram companion with stable follower heartbeats, exact queue-transition notices, pressure-safe Workspace rotation, adaptive Thread continuity, files, voice, controls, and bundled Telegram interaction Skills |
|
|
19
19
|
| [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
|
|
20
20
|
|
|
21
21
|
Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
|
-
Requires **Pi 0.87.0+** and **Node.js 22.19.0+**. State Flow 0.17 introduces a breaking storage-format boundary with no in-place predecessor converter. Preserve existing State Flow stores and review the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.18.
|
|
25
|
+
Requires **Pi 0.87.0+** and **Node.js 22.19.0+**. State Flow 0.17 introduces a breaking storage-format boundary with no in-place predecessor converter. Preserve existing State Flow stores and review the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.18.1/docs/usage.md#moving-a-store-and-the-017-format-boundary) before upgrading from an earlier kit.
|
|
26
26
|
|
|
27
27
|
From npm:
|
|
28
28
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
- Read and write only regular non-symlink State Flow-owned files at those exact paths, publish each file by same-directory atomic rename, preserve arbitrary repository contents, and classify ownership exactly. Retain opaque source bytes for file identity and rollback, not decoded-text reconstructions. Use validated structural JSON equality for in-process semantic comparisons; reserve cryptographic hashes for compact identities that cross persistence/process boundaries. Serialize cooperating canonical-file writers through file-cohort exclusion and CAS. Recheck prepared bases before each replacement/deletion and restore only bytes still matching the publisher's own output. Preserve detected concurrent changes and report unresolved rollback conflicts; do not claim kernel-atomic multi-file CAS against nonparticipating writers. Inspect only exact already-registered artifact paths; do not discover or own a Knowledge directory.
|
|
22
22
|
- At instance initialization resolve the repository, CWD key, and session key; load each scope's anchored canonical checkpoint and tail, materialize at the selected retained boundary, then overlay `global → cwd → session`. Install cached view and file publication basis atomically only after successful restoration/initialization. Revalidate mutable file cohorts; never turn inspection into a long-lived publication-basis cache. Preserve selected retained-boundary evidence across transient restore failure so explicit Start can retry it. A genuinely new session gets an empty session layer and inherits only global/CWD values; it must never reuse another same-CWD session layer. Explicit native fork adoption follows the [session-copy contract](docs/fork-contract.md): copy the proven retained source-session stream and provenance into a distinct fresh canonical origin over current shared streams, without modifying parent-private files. Reject occupied targets and checkpoint the child only after CAS acceptance. Inherited parent pointers never authorize an empty reset; fence parent passive Stop projection across child reload.
|
|
23
23
|
- Bind active Pi checkpoints to retained semantic boundaries in the current canonical lineage. Resume and tree restoration select only retained private session history while global/CWD scopes remain live; every failure resolving a selected boundary fails closed without falling through to older checkpoints, disabled markers, Git revisions, or unrelated newer private state. Passive shared-memory availability does not prove the selected private layer: preserve the recovery failure, reject session reads and all publication until it is resolved, and let explicit Start retry the exact selection. Never checkpoint a passive substitute over failed branch recovery.
|
|
24
|
-
- Every accepted semantic change, including session-only and response-only changes, atomically updates affected canonical checkpoint/tail pairs and temporal metadata without creating a Git commit. Each materially changed scope advances its own persisted revision exactly once; Global and CWD revisions are shared across their writers, Session is private, and Effective is represented by the `G#/C#/S#` revision vector rather than an invented scalar owner. After an accepted turn reconciles its response and reaches `agent_before_settle`, one best-effort backup may commit only State Flow-owned already-written files. Acquire the backup mutex before briefly locking canonical storage for bounded root/CWD/session namespace inventory and regular-file byte capture; canonical writers never acquire the backup mutex. Release the canonical lock before every Git command, filter, staging write, ref update, or index synchronization. Stage only the captured snapshot through a private temporary worktree/index, preserve Git ignore/filter policy, and never inventory artifact sources or unrelated directory trees. Keep unrelated staged/index-only content and worktree edits intact; synchronize only exact owned index paths, include HEAD-owned deletions even when already absent from the caller index, and never create an unowned-only initial backup. Backup failure is diagnostic-only and never rolls back state, suppresses an answer, requests continuation, or blocks a later turn. After each successful backup attempt, asynchronously push the exact current commit to the attached branch's explicitly configured remote/ref without force.
|
|
24
|
+
- Every accepted semantic change, including session-only and response-only changes, atomically updates affected canonical checkpoint/tail pairs and temporal metadata without creating a Git commit. Each materially changed scope advances its own persisted revision exactly once; Global and CWD revisions are shared across their writers, Session is private, and Effective is represented by the `G#/C#/S#` revision vector rather than an invented scalar owner. After an accepted turn reconciles its response and reaches `agent_before_settle`, one best-effort backup may commit only State Flow-owned already-written files. Acquire the backup mutex before briefly locking canonical storage for bounded root/CWD/session namespace inventory and regular-file byte capture; canonical writers never acquire the backup mutex. Release the canonical lock before every Git command, filter, staging write, ref update, or index synchronization. Stage only the captured snapshot through a private temporary worktree/index, preserve Git ignore/filter policy, and never inventory artifact sources or unrelated directory trees. Keep unrelated staged/index-only content and worktree edits intact; synchronize only exact owned index paths, include HEAD-owned deletions even when already absent from the caller index, and never create an unowned-only initial backup. Backup failure is diagnostic-only and never rolls back state, suppresses an answer, requests continuation, or blocks a later turn. After each successful backup attempt, asynchronously push the exact current commit to the attached branch's explicitly configured remote/ref without force. Permit at most one in-flight push per repository root in process; skip overlapping attempts, leaving a later accepted turn to push the latest HEAD. Session shutdown waits for the in-flight process to close, within the existing Git push timeout and termination behavior; suppress push-failure reporting after shutdown begins. Push failure has no semantic effect; while active, warn once per failure streak with a concise message and reset suppression after successful replication. Record the available redacted Git failure detail in the local diagnostic log even when general logging is off; if local recording fails, warn once with the available detail. A later accepted turn retries the latest backup. Durable push queues, publication workers, worker leases, and remote retry generations do not exist.
|
|
25
25
|
- Register `patch_state` as the sole model-authored semantic mutation protocol. Its canonical grammar accepts one or more fixed `global`, `cwd`, and `session` semantic patches; reject unknown fields, empty supplied scopes, material no-ops, and retired finalization or `{scope, patch}` / `unchanged` grammar. Validate every supplied scope against one causal basis and publish it all-or-nothing with one identity, temporal boundary, and durable cohort. Never accept model-authored `response`.
|
|
26
26
|
- Treat every semantic `patch_state` as a strict inference barrier. Give it sequential execution mode; find the matching synchronized assistant through public parent traversal from the selected leaf without constructing a full branch during tool preflight; require exactly one `patch_state` call in that response and block every sibling tool call before execution. The next inference sees rematerialized state. Ordinary accepted answers require no finalization patch or fallback inference.
|
|
27
27
|
- Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned, and an accepted empty answer reconciles to `""` rather than causing a finalization failure. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments. Other extensions retain ownership of their own comments and output handling.
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
- Recognize Skill acquisition only when a successful finalized `read` path exactly matches a registered Pi Skill exposed by the public `getCommands()` resource metadata. Map `sourceInfo.scope` deterministically as user → global, project → CWD, and temporary → session; never infer ownership from path shape. Hash the executed source bytes. Matching current provenance creates no acquisition request. Otherwise append a concise tool-result hint naming the exact target, but keep the read volatile unless the model supplies durable compiler output; pending acquisition never blocks an unrelated semantic patch or ordinary completion. Validate an attempted compilation at the reported scope/path with non-empty `description`, `kind: "skill"`, and flexible non-empty `compilation`, then record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in that scope's provenance. Replace the complete prior Skill artifact and provenance on refresh so obsolete evidence cannot survive. Unregistered `SKILL.md` reads have no acquisition semantics. `contract.compiled_skills` is retired and rejected; never fabricate hashes or bypass explicit source acquisition.
|
|
32
32
|
- Make state stewardship an ordinary model responsibility without a background loop or gratuitous full-state rewrite. Every handoff curates touched and obviously stale or mis-scoped visible branches: place new knowledge at the narrowest valid scope, merge fragmented facts, compress history into conclusions, and delete stale, completed, redundant, or low-value keys while preserving active commitments and evidence. Dedicated cleanup and scope audits require an explicit user request; a feature/release/project boundary may motivate a recommendation, not an automatic audit. For a proven move within one store, inspect both owners, resolve conflicts, apply destination/source changes through one atomic multi-scope patch, and verify both owners plus the effective overlay. External transfers require destination-native acceptance verification before source deletion. Global is only established cross-project/user/environment knowledge, CWD is reusable project truth, and session is branch/run continuation.
|
|
33
33
|
- Accept omitted semantic fields inside each supplied scope patch, but reject empty supplied scopes and require at least one material semantic or provenance change. When no state change is needed, do not call `patch_state`. Accept the exact ordinary answer, including an empty string, which runtime owns as session `response`. A changed response is a semantic transition; identical complete semantic state finalizes lifecycle without a patch, identity, or temporal step. Semantic usefulness and optimization remain protocol-owned because deterministic validation cannot prove them.
|
|
34
|
-
- Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and response-finalization failures. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity and call id; successful patches and accepted answers are never logged. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes enablement or accepted state.
|
|
34
|
+
- Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and response-finalization failures; asynchronous Git push failures are logged there even without opt-in so terse interactive warnings retain local detail. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity and call id; successful patches and accepted answers are never logged. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes enablement or accepted state.
|
|
35
35
|
- Keep `applyPatch` results and staged mutable drafts detached from caller patches and accepted scopes. Detach once at the public patch boundary; private owned-draft COW may share untouched paths only within that isolated basis. Clone incoming replacements, detach inherited objects before preserving their merge behavior, and retain late-response/artifact mutation isolation. Do not trade CAS, publication rollback or public/historical read isolation for cross-boundary sharing.
|
|
36
36
|
- Recursively materialize patches immediately; empty objects preserve, nested object-key `null` deletes, and `null` anywhere in semantic state including arrays is invalid. This prohibition does not apply to runtime envelopes such as an origin's null parent. Persist runtime-normalized replay patches that exactly reproduce accepted state, including complete artifact replacement; runtime-owned provenance is stored in scope `meta.json` and is not part of semantic replay.
|
|
37
37
|
- Do not impose project schemas, state or patch byte caps, dynamic growth pressure, observation envelopes, action authorization, action ledgers, or state-size limits.
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.18.1: Backup Reliability
|
|
6
|
+
|
|
7
|
+
- `Push lifecycle`: Bounds backup replication to one in-flight push per repository, skips overlap until a later accepted turn, and awaits process closure at session shutdown under the existing push timeout. Suppresses failure reporting after shutdown starts; test fixtures settle pushes before cleanup. Canonical acceptance stays independent of Git replication.
|
|
8
|
+
- `Push diagnostics`: Repeated asynchronous push failures produce one concise warning per failure streak until a successful push. Available redacted Git errors are recorded locally even without general logging; an unavailable diagnostic log falls back to one warning with the failure detail. Canonical acceptance and later-turn retry are unchanged.
|
|
9
|
+
- `Prompt-prefix evidence`: Version 3 synthetic benchmark reports each inference's serialized context size and within-run shared byte prefix, accepted patch barriers, and matching native user/specification value sizes for State Flow and native Pi, including isolated resume probes. A source-bound local sample and regression coverage inform a later projection decision without changing 0.18.1 model behavior.
|
|
10
|
+
|
|
5
11
|
## 0.18.0: Independent Revisions and Replicated Backups
|
|
6
12
|
|
|
7
13
|
- `Independent scope revisions`: Persists independent Global, CWD and Session semantic counters. Each materially changed scope advances once per atomic cohort; no-ops advance none, Session owns response changes, folding preserves counts, and pre-revision 0.17 stores start from retained-tail evidence. Effective uses the truthful `G#/C#/S#` vector rather than a scalar observer count. Metadata v2 keeps v1 readable and fences older writers after a revision-aware scope write.
|
|
@@ -124,11 +124,11 @@ Array ranges, structural `keys` reads and path-intersected `patch` projections s
|
|
|
124
124
|
|
|
125
125
|
The default store is `~/.pi/agent/state-flow/`, independent of registered source files. Canonical `checkpoint.json`, `patches.jsonl` and `meta.json` files hold each scope's state, retained changes and metadata; session configuration and runtime identity are stored separately.
|
|
126
126
|
|
|
127
|
-
**Git backups are optional.** When the store is a configured Git repository, accepted active turns may create versioned backups of State Flow-owned files. If the attached branch has an explicitly configured remote, State Flow then pushes the exact current backup commit there asynchronously and without force.
|
|
127
|
+
**Git backups are optional.** When the store is a configured Git repository, accepted active turns may create versioned backups of State Flow-owned files. If the attached branch has an explicitly configured remote, State Flow then pushes the exact current backup commit there asynchronously and without force. Within one Pi process, only one push per repository can run at a time; overlapping attempts are skipped and a later accepted turn pushes the latest backup. Session shutdown waits for that repository's active push to close or time out. Commit failures warn locally; repeated push failures produce one concise warning until a successful push, with redacted Git detail kept in the local diagnostic log. Neither failure rejects or rolls back accepted memory. Backup needs a Git commit identity, but accepting and persisting state does not. Git history can be inspected separately, but it is not the authority for `read_state` or automatic restoration of expired semantic boundaries.
|
|
128
128
|
|
|
129
129
|
Resume and tree navigation restore the selected retained session boundary over current shared global/CWD memory. A new session gets its own session layer. Supported native forks copy selected session state into a new owner without changing the parent's private data. Expired, incomplete or contradictory boundaries fail closed rather than silently substituting newer state. See [fork support](docs/usage.md#fork-support-and-limits) and [storage recovery](docs/usage.md#storage-and-recovery).
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
The canonical storage-format boundary introduced in 0.17 still applies: current versions accept only the canonical store contract and provide no in-place predecessor converter. Preserve existing data and check the [format boundary](docs/usage.md#moving-a-store-and-the-017-format-boundary) before changing versions or moving a store.
|
|
132
132
|
|
|
133
133
|
## Operational boundaries
|
|
134
134
|
|
|
@@ -12,7 +12,7 @@ import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, pa
|
|
|
12
12
|
import { readNativeSessionHeader } from "./continuation.js";
|
|
13
13
|
import { cwdScopeKey, resolveSessionAddress, sessionScopeKey, } from "./durable.js";
|
|
14
14
|
import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.js";
|
|
15
|
-
import { backupCurrentStateFlowFiles,
|
|
15
|
+
import { awaitInFlightBackupPushes, backupCurrentStateFlowFiles, startStateFlowBackupPush } from "./git.js";
|
|
16
16
|
import { projectRecentTransitionsWithLimit } from "./history.js";
|
|
17
17
|
import { isObject, presentationJson, sameJson } from "./json.js";
|
|
18
18
|
import { StateFlowDiagnosticWriter, stateFlowLogPath } from "./logging.js";
|
|
@@ -62,6 +62,8 @@ export default function stateFlowExtension(pi, options = {}) {
|
|
|
62
62
|
const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
|
|
63
63
|
const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
|
|
64
64
|
let backupPending = false;
|
|
65
|
+
let shuttingDown = false;
|
|
66
|
+
let pushFailureNotified = false;
|
|
65
67
|
const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
|
|
66
68
|
? registeredSkillResolver(activeContext.cwd, pi.getCommands())(path)
|
|
67
69
|
: undefined);
|
|
@@ -932,11 +934,18 @@ export default function stateFlowExtension(pi, options = {}) {
|
|
|
932
934
|
backupCurrentStateFlowFiles(repositoryRoot);
|
|
933
935
|
const pushSessionId = sessionAddress(ctx).id;
|
|
934
936
|
const pushCwd = ctx.cwd;
|
|
935
|
-
|
|
937
|
+
startStateFlowBackupPush(repositoryRoot, (error) => {
|
|
938
|
+
if (shuttingDown)
|
|
939
|
+
return;
|
|
936
940
|
const message = error instanceof Error ? error.message : String(error);
|
|
937
|
-
diagnosticWriter.
|
|
938
|
-
|
|
939
|
-
|
|
941
|
+
const recorded = diagnosticWriter.recordBackupPushFailure(pushSessionId, pushCwd, message);
|
|
942
|
+
if (pushFailureNotified)
|
|
943
|
+
return;
|
|
944
|
+
pushFailureNotified = true;
|
|
945
|
+
notifyActiveContext(recorded
|
|
946
|
+
? `State Flow Git backup push failed; state is saved locally. Details: ${stateFlowLogPath(agentDir)}. A later accepted turn retries.`
|
|
947
|
+
: `State Flow Git backup push failed; local diagnostics unavailable: ${message}`);
|
|
948
|
+
}, () => { pushFailureNotified = false; });
|
|
940
949
|
}
|
|
941
950
|
}
|
|
942
951
|
catch (error) {
|
|
@@ -980,11 +989,13 @@ export default function stateFlowExtension(pi, options = {}) {
|
|
|
980
989
|
runAnchorTimestamp = undefined;
|
|
981
990
|
restoreActiveBranch(ctx);
|
|
982
991
|
});
|
|
983
|
-
pi.on("session_shutdown", (_event, _ctx) => {
|
|
992
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
993
|
+
shuttingDown = true;
|
|
984
994
|
telegramStartPending = false;
|
|
985
995
|
compactionStopped = true;
|
|
986
996
|
completedRunAccepted = false;
|
|
987
997
|
activeContext = undefined;
|
|
988
998
|
telegram.dispose();
|
|
999
|
+
await awaitInFlightBackupPushes(repositoryRoot);
|
|
989
1000
|
});
|
|
990
1001
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** Commit only current State Flow-owned files; never changes canonical acceptance. */
|
|
2
2
|
export declare function backupCurrentStateFlowFiles(repositoryRoot: string): string | undefined;
|
|
3
|
+
/** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
|
|
4
|
+
export declare function startStateFlowBackupPush(repositoryRoot: string, onFailure: (error: unknown) => void, onSuccess?: () => void): boolean;
|
|
5
|
+
/** Resolve only after the push process has closed (including timeout termination). */
|
|
6
|
+
export declare function awaitInFlightBackupPushes(repositoryRoot: string): Promise<void>;
|
|
3
7
|
/** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
|
|
4
8
|
export declare function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{
|
|
5
9
|
commit: string;
|
|
@@ -7,6 +7,7 @@ import { captureOwnedFileBases, isStateFlowOwnedPath } from "./durable.js";
|
|
|
7
7
|
import { acquirePublicationLock, withStoragePublicationLock } from "./storage.js";
|
|
8
8
|
const GIT_TIMEOUT_MS = 15_000;
|
|
9
9
|
const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
|
|
10
|
+
const activePushes = new Map();
|
|
10
11
|
function redactGitDiagnostic(value) {
|
|
11
12
|
return value
|
|
12
13
|
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@")
|
|
@@ -205,6 +206,33 @@ function commitCurrentOwnedFiles(repositoryRoot, expectedHead) {
|
|
|
205
206
|
export function backupCurrentStateFlowFiles(repositoryRoot) {
|
|
206
207
|
return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
|
|
207
208
|
}
|
|
209
|
+
/** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
|
|
210
|
+
export function startStateFlowBackupPush(repositoryRoot, onFailure, onSuccess) {
|
|
211
|
+
const root = resolve(repositoryRoot);
|
|
212
|
+
if (activePushes.has(root))
|
|
213
|
+
return false;
|
|
214
|
+
const push = pushCurrentStateFlowBackup(root).then((result) => {
|
|
215
|
+
if (result) {
|
|
216
|
+
try {
|
|
217
|
+
onSuccess?.();
|
|
218
|
+
}
|
|
219
|
+
catch { /* Reporting cannot change push acceptance. */ }
|
|
220
|
+
}
|
|
221
|
+
}, (error) => {
|
|
222
|
+
try {
|
|
223
|
+
onFailure(error);
|
|
224
|
+
}
|
|
225
|
+
catch { /* Reporting cannot revive a failed push. */ }
|
|
226
|
+
}).finally(() => { activePushes.delete(root); });
|
|
227
|
+
activePushes.set(root, push);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
/** Resolve only after the push process has closed (including timeout termination). */
|
|
231
|
+
export async function awaitInFlightBackupPushes(repositoryRoot) {
|
|
232
|
+
const push = activePushes.get(resolve(repositoryRoot));
|
|
233
|
+
if (push)
|
|
234
|
+
await push;
|
|
235
|
+
}
|
|
208
236
|
/** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
|
|
209
237
|
export function pushCurrentStateFlowBackup(repositoryRoot) {
|
|
210
238
|
return new Promise((resolvePush, rejectPush) => {
|
|
@@ -217,7 +245,7 @@ export function pushCurrentStateFlowBackup(repositoryRoot) {
|
|
|
217
245
|
destination = configuredPushDestination(root);
|
|
218
246
|
}
|
|
219
247
|
catch (error) {
|
|
220
|
-
rejectPush(error);
|
|
248
|
+
rejectPush(new Error(redactGitDiagnostic(error instanceof Error ? error.message : String(error))));
|
|
221
249
|
return;
|
|
222
250
|
}
|
|
223
251
|
if (commit === undefined || destination === undefined) {
|
|
@@ -36,4 +36,7 @@ export declare class StateFlowDiagnosticWriter {
|
|
|
36
36
|
private readonly notify;
|
|
37
37
|
constructor(enabled: boolean, path: string, repositoryRoot: string, notify: (message: string) => void);
|
|
38
38
|
record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras?: DiagnosticExtras): void;
|
|
39
|
+
/** Push warnings stay short; retain the available Git failure detail locally even without opt-in logging. */
|
|
40
|
+
recordBackupPushFailure(sessionId: string, cwd: string, error: string): boolean;
|
|
41
|
+
private write;
|
|
39
42
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Domain: opt-in diagnostic capture for rejected State Flow resolutions.
|
|
2
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { isObject } from "./json.js";
|
|
5
5
|
/** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
|
|
@@ -18,9 +18,26 @@ export function projectDiagnosticContent(content) {
|
|
|
18
18
|
export function stateFlowLogPath(agentDir) {
|
|
19
19
|
return join(agentDir, "tmp", "state-flow", "logs.jsonl");
|
|
20
20
|
}
|
|
21
|
+
function ensureDiagnosticDirectory(path) {
|
|
22
|
+
if (dirname(path) !== path)
|
|
23
|
+
ensureDiagnosticDirectory(dirname(path));
|
|
24
|
+
if (!lstatSync(path, { throwIfNoEntry: false }))
|
|
25
|
+
mkdirSync(path);
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
28
|
+
throw new Error(`State Flow diagnostic directory is not a regular directory: ${path}`);
|
|
29
|
+
}
|
|
21
30
|
export function appendStateFlowDiagnostic(path, record) {
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
ensureDiagnosticDirectory(dirname(path));
|
|
32
|
+
const descriptor = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
33
|
+
try {
|
|
34
|
+
if (!fstatSync(descriptor).isFile())
|
|
35
|
+
throw new Error(`State Flow diagnostic destination is not a regular file: ${path}`);
|
|
36
|
+
appendFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(descriptor);
|
|
40
|
+
}
|
|
24
41
|
}
|
|
25
42
|
/** Own diagnostic path safety, projection, persistence, and one-shot failure reporting. */
|
|
26
43
|
export class StateFlowDiagnosticWriter {
|
|
@@ -38,6 +55,13 @@ export class StateFlowDiagnosticWriter {
|
|
|
38
55
|
record(sessionId, cwd, error, category, extras = {}) {
|
|
39
56
|
if (!this.enabled)
|
|
40
57
|
return;
|
|
58
|
+
this.write(sessionId, cwd, error, category, extras);
|
|
59
|
+
}
|
|
60
|
+
/** Push warnings stay short; retain the available Git failure detail locally even without opt-in logging. */
|
|
61
|
+
recordBackupPushFailure(sessionId, cwd, error) {
|
|
62
|
+
return this.write(sessionId, cwd, error, "publication-conflict", {}, false);
|
|
63
|
+
}
|
|
64
|
+
write(sessionId, cwd, error, category, extras, reportFailure = true) {
|
|
41
65
|
try {
|
|
42
66
|
const fromRepository = relative(this.repositoryRoot, this.path);
|
|
43
67
|
if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
|
|
@@ -54,12 +78,14 @@ export class StateFlowDiagnosticWriter {
|
|
|
54
78
|
...(extras.tool === undefined ? {} : { tool: extras.tool }),
|
|
55
79
|
...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
|
|
56
80
|
});
|
|
81
|
+
return true;
|
|
57
82
|
}
|
|
58
83
|
catch (failure) {
|
|
59
|
-
if (this.warningReported)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
if (reportFailure && !this.warningReported) {
|
|
85
|
+
this.warningReported = true;
|
|
86
|
+
this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
91
|
}
|
|
@@ -10,7 +10,7 @@ The current repository-local stack uses Linux/x64, Node 26.8.1, Git 2.55.0, and
|
|
|
10
10
|
|
|
11
11
|
| Pi SDK stack | Validation | Evidence status |
|
|
12
12
|
| --- | --- | --- |
|
|
13
|
-
| 0.87.0 | Build, typecheck, import, package dry-run
|
|
13
|
+
| 0.87.0 | Build, typecheck, import, package dry-run; 472/472 tests in each of five ordinary and five `push.negotiate=true` isolated-Git runs | 0.18.1 candidate source-bound acceptance (2026-09-23); not a live-provider or cross-platform claim |
|
|
14
14
|
| 0.84.4 | Historical full-suite baseline | Unsupported by State Flow 0.17.0 |
|
|
15
15
|
| 0.85.1 | Historical full-suite baseline | Unsupported by State Flow 0.17.0 |
|
|
16
16
|
|
|
@@ -22,16 +22,24 @@ BENCH_PATCHES=2 BENCH_SAMPLES=1 BENCH_ROUNDS=2 BENCH_STATE_BYTES=1024 npm run be
|
|
|
22
22
|
|
|
23
23
|
Treat timings as observations from the named host and source identity, not universal thresholds. Compare runs only when workload fingerprints, dependencies, payloads, and validation outcomes match. A failed correctness probe invalidates its timing sample.
|
|
24
24
|
|
|
25
|
+
### Within-run prompt-prefix probe (0.18.1)
|
|
26
|
+
|
|
27
|
+
The v3 report records `promptPrefixRuns` for each State Flow and native Pi user run, with no duplication across lifecycle checkpoints. Per inference, `contextBytes` is the UTF-8 byte length of `JSON.stringify(context.messages)` as seen by the installed faux provider; `sharedPrefixBytes` is the longest common byte prefix of that serialization with the previous inference **in the same run**, or `null` on the first inference. Per run, `patchStateBarriers` counts successful tool completions, and `nativeUserBytes` / `specificationBytes` count JSON-serialized *string values*, excluding their containing message/field frames (`specificationBytes` is `null` for native Pi). Isolated post-resume probes expose the same per-run metrics.
|
|
28
|
+
|
|
29
|
+
A bounded local sample used `BENCH_PATCHES=2 BENCH_SAMPLES=1 BENCH_ROUNDS=2 BENCH_STATE_BYTES=1024 BENCH_POST_RESUME=1 npm run benchmark` on Node 26.8.1 Linux/x64 and Pi/AI 0.87.0. The runtime-source SHA-256 was `0f4eea2394c52c6ac545260cae967a16581b8768a24a8c611c13a389e6df01a9`, workload-source SHA-256 `e9b8158d953d8622776aa075835c4aa95c98855ed5c90e85de05dccae1087e91`, base commit `5f0f688650738d4d0e9850b2529b0273c47121a8` (the measured tree had uncommitted 0.18.1 changes). Both source hashes stayed unchanged during the run and all correctness phases passed. At user run 1, native Pi's second inference shared 2,776 of 2,777 prior serialized bytes; State Flow shared 9,365 of 9,467 at inference 2 and 9,173 with inference 2 at inference 3 (one accepted barrier). In both State Flow runs, the 21-byte serialized `specification` value duplicated the 21-byte current native user string value; native Pi had no projection field. These are synthetic short-run observations, not representative cache-hit rates or timing predictions.
|
|
30
|
+
|
|
31
|
+
A later 0.19 decision about freezing the projected head must compare compatible workloads and include correctness of fresh state visibility after barriers. Byte-prefix measurements omit provider framing, tool schemas, tokenization, cache policies and quality; this probe does **not** justify changing context projection in 0.18.1.
|
|
32
|
+
|
|
25
33
|
## Current cost model
|
|
26
34
|
|
|
27
35
|
- Current semantic projection is proportional to projected state size.
|
|
28
36
|
- Retained temporal reads are bounded by configured `historyLimit` (`0..100`, default `7`).
|
|
29
37
|
- Canonical publication writes the affected scope/runtime cohort under file CAS and cooperating-writer exclusion; it executes no Git command.
|
|
30
38
|
- Registered-artifact maintenance is proportional to already-registered paths and uses metadata-only `size + mtimeNs` inspection. It performs no directory discovery or generic body hashing.
|
|
31
|
-
- Optional Git backup runs only after accepted work reaches `agent_before_settle`. Its canonical-lock capture costs are proportional to owned file count and bytes; all Git commands and filters run after that lock is released.
|
|
39
|
+
- Optional Git backup runs only after accepted work reaches `agent_before_settle`. Its canonical-lock capture costs are proportional to owned file count and bytes; all Git commands and filters run after that lock is released. The commit remains synchronous within the calling settled-turn callback: independent canonical processes can publish during slow Git, but this is not a host-event-loop latency bound. Remote push runs asynchronously, skips overlapping attempts per repository within one Pi process, and is awaited at session shutdown within its existing timeout and process-group termination behavior. Backup is not an acceptance or recovery authority.
|
|
32
40
|
- Native transcript opening, Pi context construction, and foreign custom-context preservation remain Pi/history costs rather than canonical-state storage costs.
|
|
33
41
|
|
|
34
|
-
Discarded semantic history is unavailable.
|
|
42
|
+
Discarded semantic history is unavailable. Git cold reads, revision restoration, queue workers, migration, terminal repair and fallback inference are absent from the current architecture. Remote pushes do exist but are asynchronous and outside these local benchmark workloads; do not count them as measured costs.
|
|
35
43
|
|
|
36
44
|
## Tool-preflight parent traversal
|
|
37
45
|
|
|
@@ -46,7 +46,7 @@ The canonical store is `state-flow/` beneath the agent directory. Keeping config
|
|
|
46
46
|
- `autoStart`: Defaults to `false`. When `true`, genuinely new sessions use the same initialization as explicit Start, including fresh CWDs.
|
|
47
47
|
- `passiveBootstrap`: Defaults to `true`. Projects existing effective durable memory into ordinary model context without creating scopes, publishing, or starting an episode.
|
|
48
48
|
- `passiveTools`: Defaults to `true`. Exposes `read_state` and `patch_state` outside active episodes. Reads remain side-effect free; the first explicit patch may initialize absent canonical storage but never converts predecessor formats or enables an episode or State Flow compaction.
|
|
49
|
-
- `logging`: Defaults to `false`. When enabled, records rejected patches and accepted-answer reconciliation failures locally at `tmp/state-flow/logs.jsonl` beneath the agent directory.
|
|
49
|
+
- `logging`: Defaults to `false`. When enabled, records rejected patches and accepted-answer reconciliation failures locally at `tmp/state-flow/logs.jsonl` beneath the agent directory. Asynchronous Git push failures are recorded there even when this setting is off.
|
|
50
50
|
- `showSuccessfulPatches`: Defaults to `true`. In interactive Pi, successful `patch_state` rows show only the applied pretty-printed JSON arguments, with blank lines between adjacent memory sections; set it to `false` to keep only the compact summary. Rejected calls still use ordinary error rendering; State Flow adds no private validation turn.
|
|
51
51
|
- `historyLimit`: Defaults to `7` and accepts integers from `0` through `100`. It bounds materialized-history and scope patch-history offsets. Lowering it on reload/restore/fork folds excess tails forward without losing current state; selected boundaries outside the new window are unavailable. Zero retains only current checkpoints. Raising the limit affects only future retention and cannot reconstruct discarded history.
|
|
52
52
|
|
|
@@ -56,7 +56,7 @@ Settings are read once at extension load. After editing, use `/reload` or restar
|
|
|
56
56
|
|
|
57
57
|
### Diagnostic logging and privacy
|
|
58
58
|
|
|
59
|
-
Rejected-call records may contain exact attempted arguments and useful draft text, plus the error category and tool/call identity. Successful patches are not logged; reasoning bodies are excluded. Logs are not semantic state, scope metadata, Pi checkpoints, or publication input. If the log path overlaps a custom state repository, capture fails closed instead of committing it. A logging failure changes no accepted state and produces at most one local warning.
|
|
59
|
+
Rejected-call records may contain exact attempted arguments and useful draft text, plus the error category and tool/call identity. Asynchronous push failures retain the available redacted Git error there regardless of `logging`; interactive warnings stay short and appear once per failure streak, then reset on success. If logging the push failure is unavailable, one warning exposes the available detail instead. Successful patches are not logged; reasoning bodies are excluded. Logs are not semantic state, scope metadata, Pi checkpoints, or publication input. If the log path overlaps a custom state repository, capture fails closed instead of committing it. A logging failure changes no accepted state and produces at most one local warning.
|
|
60
60
|
|
|
61
61
|
Logs remain local unless you move them; rotation/deletion is operator-owned. Treat them and state files as private. Removing a secret from current state does not erase older offsets, Git history, native sessions, or remote copies.
|
|
62
62
|
|
|
@@ -98,13 +98,13 @@ Missing artifact provenance inside an otherwise complete scope `meta.json` means
|
|
|
98
98
|
|
|
99
99
|
Canonical scope/runtime files own current materialization and retained hot history regardless of Git availability. Pi checkpoints identify a retained semantic boundary, not a Git commit or arbitrary historical snapshot. Restart and branch restoration fail closed when the selected boundary has expired rather than substituting newer files as the selected past.
|
|
100
100
|
|
|
101
|
-
After an accepted turn has reconciled its response, Pi 0.87's final actionable `agent_before_settle` boundary may create one best-effort backup commit when Git is available. If the attached branch has an explicitly configured remote/ref, State Flow starts a non-interactive asynchronous push of the exact current commit without force. Settlement does not wait for the network.
|
|
101
|
+
After an accepted turn has reconciled its response, Pi 0.87's final actionable `agent_before_settle` boundary may create one best-effort backup commit when Git is available. If the attached branch has an explicitly configured remote/ref, State Flow starts a non-interactive asynchronous push of the exact current commit without force. Settlement does not wait for the network. Within one Pi process, an in-flight push per repository skips overlapping attempts; a later accepted turn retries the latest backup without a durable queue. Session shutdown waits for that repository's in-flight push to close or time out, suppressing push-failure reporting after shutdown begins. Commit or push failure is diagnostic-only; repeated push failures warn once per failure streak and remain locally diagnosable. Git availability never changes semantic authority, step, or retained lineage.
|
|
102
102
|
|
|
103
103
|
### Moving a store and the 0.17 format boundary
|
|
104
104
|
|
|
105
105
|
An SDK `repositoryRoot` override or a different `PI_CODING_AGENT_DIR` selects a location; it does not relocate existing state or retained history. Copy the complete canonical store while all writers are quiescent, or use a genuinely new Pi session for an independent store. Copying only current checkpoints without their tails and metadata cannot preserve retained boundaries.
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
Starting with 0.17, State Flow accepts only its canonical checkpoint/tail, temporal metadata, and separate session config/runtime contract; that boundary still applies to current versions. A canonical 0.17 scope written before independent revisions remains readable: its initial counter uses only the retained semantic tail and is persisted in metadata version 2 on the next owned write, without inventing folded ancestry. Version 1 remains readable; older writers refuse version 2 through the existing provenance-version fence, so cooperating instances should upgrade together. Predecessor checkpoint envelopes, combined session metadata, pre-intents checkpoints, `state.json`, hashed layouts, and semantic Pi checkpoint envelopes fail as unsupported without rewriting existing bytes. State Flow does not provide an in-place converter; external conversion or a fresh store is operator-owned.
|
|
108
108
|
|
|
109
109
|
### Conflicts and interrupted publication
|
|
110
110
|
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type SessionAddress,
|
|
26
26
|
} from "./durable.ts";
|
|
27
27
|
import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
|
|
28
|
-
import { backupCurrentStateFlowFiles,
|
|
28
|
+
import { awaitInFlightBackupPushes, backupCurrentStateFlowFiles, startStateFlowBackupPush } from "./git.ts";
|
|
29
29
|
import { projectRecentTransitionsWithLimit } from "./history.ts";
|
|
30
30
|
import { isObject, presentationJson, sameJson, type JsonObject } from "./json.ts";
|
|
31
31
|
import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
|
|
@@ -88,6 +88,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
88
88
|
const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
|
|
89
89
|
const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
|
|
90
90
|
let backupPending = false;
|
|
91
|
+
let shuttingDown = false;
|
|
92
|
+
let pushFailureNotified = false;
|
|
91
93
|
const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
|
|
92
94
|
? registeredSkillResolver(activeContext.cwd, pi.getCommands())(path)
|
|
93
95
|
: undefined);
|
|
@@ -935,11 +937,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
935
937
|
backupCurrentStateFlowFiles(repositoryRoot);
|
|
936
938
|
const pushSessionId = sessionAddress(ctx).id;
|
|
937
939
|
const pushCwd = ctx.cwd;
|
|
938
|
-
|
|
940
|
+
startStateFlowBackupPush(repositoryRoot, (error) => {
|
|
941
|
+
if (shuttingDown) return;
|
|
939
942
|
const message = error instanceof Error ? error.message : String(error);
|
|
940
|
-
diagnosticWriter.
|
|
941
|
-
|
|
942
|
-
|
|
943
|
+
const recorded = diagnosticWriter.recordBackupPushFailure(pushSessionId, pushCwd, message);
|
|
944
|
+
if (pushFailureNotified) return;
|
|
945
|
+
pushFailureNotified = true;
|
|
946
|
+
notifyActiveContext(recorded
|
|
947
|
+
? `State Flow Git backup push failed; state is saved locally. Details: ${stateFlowLogPath(agentDir)}. A later accepted turn retries.`
|
|
948
|
+
: `State Flow Git backup push failed; local diagnostics unavailable: ${message}`);
|
|
949
|
+
}, () => { pushFailureNotified = false; });
|
|
943
950
|
}
|
|
944
951
|
} catch (error) {
|
|
945
952
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -981,11 +988,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
981
988
|
runAnchorTimestamp = undefined;
|
|
982
989
|
restoreActiveBranch(ctx);
|
|
983
990
|
});
|
|
984
|
-
pi.on("session_shutdown", (_event, _ctx) => {
|
|
991
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
992
|
+
shuttingDown = true;
|
|
985
993
|
telegramStartPending = false;
|
|
986
994
|
compactionStopped = true;
|
|
987
995
|
completedRunAccepted = false;
|
|
988
996
|
activeContext = undefined;
|
|
989
997
|
telegram.dispose();
|
|
998
|
+
await awaitInFlightBackupPushes(repositoryRoot);
|
|
990
999
|
});
|
|
991
1000
|
}
|
|
@@ -8,6 +8,7 @@ import { acquirePublicationLock, withStoragePublicationLock } from "./storage.ts
|
|
|
8
8
|
|
|
9
9
|
const GIT_TIMEOUT_MS = 15_000;
|
|
10
10
|
const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
|
|
11
|
+
const activePushes = new Map<string, Promise<void>>();
|
|
11
12
|
|
|
12
13
|
function redactGitDiagnostic(value: string): string {
|
|
13
14
|
return value
|
|
@@ -206,6 +207,28 @@ export function backupCurrentStateFlowFiles(repositoryRoot: string): string | un
|
|
|
206
207
|
return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/** Skip overlapping pushes; the next accepted turn can push the latest HEAD. */
|
|
211
|
+
export function startStateFlowBackupPush(repositoryRoot: string, onFailure: (error: unknown) => void, onSuccess?: () => void): boolean {
|
|
212
|
+
const root = resolve(repositoryRoot);
|
|
213
|
+
if (activePushes.has(root)) return false;
|
|
214
|
+
const push = pushCurrentStateFlowBackup(root).then(
|
|
215
|
+
(result) => {
|
|
216
|
+
if (result) { try { onSuccess?.(); } catch { /* Reporting cannot change push acceptance. */ } }
|
|
217
|
+
},
|
|
218
|
+
(error) => {
|
|
219
|
+
try { onFailure(error); } catch { /* Reporting cannot revive a failed push. */ }
|
|
220
|
+
},
|
|
221
|
+
).finally(() => { activePushes.delete(root); });
|
|
222
|
+
activePushes.set(root, push);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Resolve only after the push process has closed (including timeout termination). */
|
|
227
|
+
export async function awaitInFlightBackupPushes(repositoryRoot: string): Promise<void> {
|
|
228
|
+
const push = activePushes.get(resolve(repositoryRoot));
|
|
229
|
+
if (push) await push;
|
|
230
|
+
}
|
|
231
|
+
|
|
209
232
|
/** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
|
|
210
233
|
export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ commit: string; remote: string; ref: string } | undefined> {
|
|
211
234
|
return new Promise((resolvePush, rejectPush) => {
|
|
@@ -217,7 +240,7 @@ export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ co
|
|
|
217
240
|
commit = currentHead(root);
|
|
218
241
|
destination = configuredPushDestination(root);
|
|
219
242
|
} catch (error) {
|
|
220
|
-
rejectPush(error);
|
|
243
|
+
rejectPush(new Error(redactGitDiagnostic(error instanceof Error ? error.message : String(error))));
|
|
221
244
|
return;
|
|
222
245
|
}
|
|
223
246
|
if (commit === undefined || destination === undefined) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Domain: opt-in diagnostic capture for rejected State Flow resolutions.
|
|
2
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { isObject } from "./json.ts";
|
|
5
5
|
|
|
@@ -39,9 +39,22 @@ export function stateFlowLogPath(agentDir: string): string {
|
|
|
39
39
|
return join(agentDir, "tmp", "state-flow", "logs.jsonl");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function ensureDiagnosticDirectory(path: string): void {
|
|
43
|
+
if (dirname(path) !== path) ensureDiagnosticDirectory(dirname(path));
|
|
44
|
+
if (!lstatSync(path, { throwIfNoEntry: false })) mkdirSync(path);
|
|
45
|
+
const stat = lstatSync(path);
|
|
46
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`State Flow diagnostic directory is not a regular directory: ${path}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnosticRecord): void {
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
ensureDiagnosticDirectory(dirname(path));
|
|
51
|
+
const descriptor = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
52
|
+
try {
|
|
53
|
+
if (!fstatSync(descriptor).isFile()) throw new Error(`State Flow diagnostic destination is not a regular file: ${path}`);
|
|
54
|
+
appendFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(descriptor);
|
|
57
|
+
}
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
export interface DiagnosticExtras {
|
|
@@ -68,6 +81,15 @@ export class StateFlowDiagnosticWriter {
|
|
|
68
81
|
|
|
69
82
|
record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras = {}): void {
|
|
70
83
|
if (!this.enabled) return;
|
|
84
|
+
this.write(sessionId, cwd, error, category, extras);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Push warnings stay short; retain the available Git failure detail locally even without opt-in logging. */
|
|
88
|
+
recordBackupPushFailure(sessionId: string, cwd: string, error: string): boolean {
|
|
89
|
+
return this.write(sessionId, cwd, error, "publication-conflict", {}, false);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private write(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras, reportFailure = true): boolean {
|
|
71
93
|
try {
|
|
72
94
|
const fromRepository = relative(this.repositoryRoot, this.path);
|
|
73
95
|
if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
|
|
@@ -84,10 +106,13 @@ export class StateFlowDiagnosticWriter {
|
|
|
84
106
|
...(extras.tool === undefined ? {} : { tool: extras.tool }),
|
|
85
107
|
...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
|
|
86
108
|
});
|
|
109
|
+
return true;
|
|
87
110
|
} catch (failure) {
|
|
88
|
-
if (this.warningReported)
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
if (reportFailure && !this.warningReported) {
|
|
112
|
+
this.warningReported = true;
|
|
113
|
+
this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
91
116
|
}
|
|
92
117
|
}
|
|
93
118
|
}
|