pi-model-fallback 0.5.3 → 0.5.7
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/README.md +2 -0
- package/docs/pi-sdk-compatibility.md +25 -0
- package/docs/usage.md +2 -0
- package/docs/verification/pi-sdk-smoke.json +103 -0
- package/extensions/index.ts +8 -17
- package/lib/state.ts +76 -4
- package/package.json +11 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.7] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Remove the automatic `package.json` push release trigger restored in 0.5.6. Releases stay behind the explicit `release_authorized` dispatch so merged version bumps do not publish on merge, and publication is owned by the separate release lane.
|
|
8
|
+
|
|
9
|
+
## [0.5.6] - 2026-10-19
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Restore automatic releases on a `package.json` version bump pushed to `main`; the manual **Auto Release** dispatch stays available with `release_authorized` checked.
|
|
14
|
+
|
|
15
|
+
## [0.5.5] - 2026-10-19
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Serialize cooldown read-modify-write transactions across sessions and processes, publish state atomically, and coordinate reset and expiry pruning so cached snapshots cannot discard or resurrect other sessions' entries. Retry transient Windows sharing violations during atomic replacement.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Run CI on Windows as well as Ubuntu, and accept CRLF markdown when checking release docs.
|
|
24
|
+
|
|
25
|
+
## [0.5.4] - 2026-10-16
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Pin Pi SDK development dependencies to 1.2.0 and verify the extension against the actual Pi host runtime.
|
|
30
|
+
|
|
3
31
|
## [0.5.3] - 2026-10-12
|
|
4
32
|
|
|
5
33
|
### Changed
|
package/README.md
CHANGED
|
@@ -121,6 +121,8 @@ The extension stores:
|
|
|
121
121
|
|
|
122
122
|
If the package is installed project-locally and the current project references it from `.pi/settings.json`, those files live under the project `.pi/` directory. Otherwise they live under the user agent directory.
|
|
123
123
|
|
|
124
|
+
Cooldown updates re-read and merge the current state under a shared `proper-lockfile` lock, including across Pi processes. Reset uses the same lock. State is published by atomic replacement, so readers never see a partially written document; expired entries are ignored on read and removed during the next mutation. Lock acquisition is bounded (about two seconds of retries); a lock or write error is reported rather than overwriting state without the lock. A reset clears entries committed before it; a new failure committed afterward can create a new cooldown.
|
|
125
|
+
|
|
124
126
|
## Behavior notes
|
|
125
127
|
|
|
126
128
|
- Successful responses do nothing.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Pi SDK 1.2.0 compatibility
|
|
2
|
+
|
|
3
|
+
This package is verified with the frozen development/test versions:
|
|
4
|
+
|
|
5
|
+
- `@earendil-works/pi-agent-core`: `1.2.0`
|
|
6
|
+
- `@earendil-works/pi-ai`: `1.2.0`
|
|
7
|
+
- `@earendil-works/pi-coding-agent`: `1.2.0`
|
|
8
|
+
- `@earendil-works/pi-tui`: `1.2.0`
|
|
9
|
+
|
|
10
|
+
These exact versions are dev/test pins. Public peer dependency ranges are
|
|
11
|
+
unchanged; the package continues to require Pi 1.1 or later.
|
|
12
|
+
|
|
13
|
+
The upstream [Pi SDK compatibility notes](https://github.com/eiei114/pi-extension-template/blob/main/docs/pi-sdk-compatibility.md)
|
|
14
|
+
record the 1.2.0 `before_agent_start` message-type union and startup busy-state
|
|
15
|
+
changes. This extension does not read user-only event fields, so no production
|
|
16
|
+
API migration was needed. Tests now construct the current `messageType: "user"`
|
|
17
|
+
event and account for sessions being busy while startup handlers run.
|
|
18
|
+
|
|
19
|
+
From a repository checkout, run `npm run smoke:pi` to exercise the installed
|
|
20
|
+
local Pi CLI and RPC host with this extension. The deterministic offline provider
|
|
21
|
+
returns a source 429 and a successful fallback response; the harness also checks
|
|
22
|
+
a handled invalid config, a second-process reload and feature response, clean
|
|
23
|
+
shutdown, and the native host shell. It uses an isolated agent directory and
|
|
24
|
+
makes no paid inference. `npm run ci` runs the smoke as well as typecheck, tests,
|
|
25
|
+
and pack dry-run.
|
package/docs/usage.md
CHANGED
|
@@ -29,3 +29,5 @@ Cooldown defaults when a rule omits `cooldownMs`:
|
|
|
29
29
|
`Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
|
|
30
30
|
|
|
31
31
|
If `autoRetry` is disabled, the existing run is not automatically continued.
|
|
32
|
+
|
|
33
|
+
Shared cooldown state uses a cross-process lock and atomic file replacement. Concurrent failures merge against the latest file, not a session's cached snapshot. Reset takes the same lock and can repair a malformed state file; it clears earlier commits, not failures committed afterward. Expired entries are ignored when selecting a model and cleaned up during the next state mutation. Lock/write failures are reported instead of performing an unlocked overwrite.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"verification_contract": "pi-sdk-behavior-v2",
|
|
4
|
+
"sdk_versions": {
|
|
5
|
+
"@earendil-works/pi-agent-core": "1.2.0",
|
|
6
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
7
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
8
|
+
"@earendil-works/pi-tui": "1.2.0"
|
|
9
|
+
},
|
|
10
|
+
"platform": "win32",
|
|
11
|
+
"model_inference": "No paid inference; actual pinned Pi CLI/RPC, extension loader, lifecycle and fallback model runtime use a deterministic offline faux provider.",
|
|
12
|
+
"unhandled_errors": 0,
|
|
13
|
+
"extension_load_errors": 0,
|
|
14
|
+
"cases": [
|
|
15
|
+
{
|
|
16
|
+
"name": "extension_load",
|
|
17
|
+
"command": "npm run smoke:pi",
|
|
18
|
+
"exit_code": 0,
|
|
19
|
+
"passed": true,
|
|
20
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":1013,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"150821d3-944b-44d0-99e2-92a72920ec36\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"extension-load\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}",
|
|
21
|
+
"assertions": [
|
|
22
|
+
{
|
|
23
|
+
"id": "extension_registered",
|
|
24
|
+
"expected": "The actual Pi host handles the package's registered status command",
|
|
25
|
+
"observed": "{\"response\":{\"id\":\"extension-load\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"notification\":{\"type\":\"extension_ui_request\",\"id\":\"150821d3-944b-44d0-99e2-92a72920ec36\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},\"exit_code\":0}",
|
|
26
|
+
"passed": true
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "happy_path",
|
|
32
|
+
"command": "npm run smoke:pi",
|
|
33
|
+
"exit_code": 0,
|
|
34
|
+
"passed": true,
|
|
35
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":1014,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"f2cfe8b5-18e7-4054-8649-707c1224832e\",\"method\":\"setStatus\",\"statusKey\":\"model-fallback\"},{\"id\":\"happy-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}},{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Exercise the deterministic fallback response.\"}],\"timestamp\":1792131439674}},{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\",\"usage\":{\"input\":563,\"output\":0,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1126,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"Error: 429 deterministic smoke failure\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\",\"usage\":{\"input\":563,\"output\":0,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1126,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"Error: 429 deterministic smoke failure\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"},\"toolResults\":[]},{\"type\":\"extension_ui_request\",\"id\":\"8a3bc896-7b4d-4b0a-bcc2-83193ca06029\",\"method\":\"setStatus\",\"statusKey\":\"model-fallback\",\"statusText\":\"fallback:pi-sdk-smoke-target/target\"},{\"type\":\"extension_ui_request\",\"id\":\"919dd5a4-0993-4101-83c8-d29ecc31f3d5\",\"method\":\"notify\",\"message\":\"Model fallback: pi-sdk-smoke-source/source → pi-sdk-smoke-target/target (429). Future sessions preselect fallback until 2026-10-16T06:18:19.680Z.\",\"notifyType\":\"warning\"},{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"pi-model-fallback-smoke-ok\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\",\"usage\":{\"input\":563,\"output\":7,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1133,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"pi-model-fallback-smoke-ok\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\",\"usage\":{\"input\":563,\"output\":7,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1133,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"},\"toolResults\":[]},{\"type\":\"agent_settled\",\"aborted\":false}],\"stderr\":\"\",\"error\":null}",
|
|
36
|
+
"assertions": [
|
|
37
|
+
{
|
|
38
|
+
"id": "feature_response",
|
|
39
|
+
"expected": "The real Pi run handles a 429, persists the configured fallback, and returns the target response",
|
|
40
|
+
"observed": "{\"response\":{\"id\":\"happy-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}},\"source_error\":{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\",\"usage\":{\"input\":563,\"output\":0,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1126,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"Error: 429 deterministic smoke failure\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},\"fallback_response\":{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"pi-model-fallback-smoke-ok\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\",\"usage\":{\"input\":563,\"output\":7,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1133,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},\"fallback_notification\":{\"type\":\"extension_ui_request\",\"id\":\"919dd5a4-0993-4101-83c8-d29ecc31f3d5\",\"method\":\"notify\",\"message\":\"Model fallback: pi-sdk-smoke-source/source → pi-sdk-smoke-target/target (429). Future sessions preselect fallback until 2026-10-16T06:18:19.680Z.\",\"notifyType\":\"warning\"},\"agent_settled\":{\"type\":\"agent_settled\",\"aborted\":false},\"user_message_count\":1,\"persisted_state\":{\"source\":{\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\"},\"fallback\":{\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\"},\"status\":429,\"until\":\"2026-10-16T06:18:19.680Z\",\"createdAt\":\"2026-10-16T06:17:19.680Z\"},\"exit_code\":0}",
|
|
41
|
+
"passed": true
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "error_path",
|
|
47
|
+
"command": "npm run smoke:pi",
|
|
48
|
+
"exit_code": 0,
|
|
49
|
+
"passed": true,
|
|
50
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":963,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"8ed2cb69-da08-4c3b-b9aa-3b2e042617c8\",\"method\":\"notify\",\"message\":\"Model fallback config error: Unsupported config version: 2.\",\"notifyType\":\"warning\"},{\"type\":\"extension_ui_request\",\"id\":\"739c37cd-366e-46fa-b25a-49c957448732\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"error-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}",
|
|
51
|
+
"assertions": [
|
|
52
|
+
{
|
|
53
|
+
"id": "handled_error",
|
|
54
|
+
"expected": "The actual extension reports a malformed config as a handled warning without crashing Pi",
|
|
55
|
+
"observed": "{\"response\":{\"id\":\"error-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"warning\":{\"type\":\"extension_ui_request\",\"id\":\"8ed2cb69-da08-4c3b-b9aa-3b2e042617c8\",\"method\":\"notify\",\"message\":\"Model fallback config error: Unsupported config version: 2.\",\"notifyType\":\"warning\"},\"exit_code\":0}",
|
|
56
|
+
"passed": true
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "reload_cleanup",
|
|
62
|
+
"command": "npm run smoke:pi",
|
|
63
|
+
"exit_code": 0,
|
|
64
|
+
"passed": true,
|
|
65
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":977,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"7eebdc06-9361-463d-8702-59b1fcf47520\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"reload-first\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}\n--- next Pi process ---\n{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":943,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"c14e9202-6c27-45c0-ba9e-08befeff728a\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"reload-status\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},{\"type\":\"extension_ui_request\",\"id\":\"f68c1ead-b99e-4ddd-a43b-e0eb798cbb35\",\"method\":\"notify\",\"message\":\"Model fallback: persistent state cleared; no remembered original model.\",\"notifyType\":\"info\"},{\"type\":\"extension_ui_request\",\"id\":\"f6e7f463-ffb9-4ac8-8cba-68a94653e32b\",\"method\":\"setStatus\",\"statusKey\":\"model-fallback\"},{\"id\":\"reload-feature\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}",
|
|
66
|
+
"assertions": [
|
|
67
|
+
{
|
|
68
|
+
"id": "reloaded_extension_registered",
|
|
69
|
+
"expected": "A second real Pi process reloads the extension and handles its registered status command",
|
|
70
|
+
"observed": "{\"response\":{\"id\":\"reload-status\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"notification\":{\"type\":\"extension_ui_request\",\"id\":\"c14e9202-6c27-45c0-ba9e-08befeff728a\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},\"second_exit_code\":0}",
|
|
71
|
+
"passed": true
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "reloaded_feature_response",
|
|
75
|
+
"expected": "The reloaded second process executes reset and returns its package-specific handled response",
|
|
76
|
+
"observed": "{\"response\":{\"id\":\"reload-feature\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"notification\":{\"type\":\"extension_ui_request\",\"id\":\"f68c1ead-b99e-4ddd-a43b-e0eb798cbb35\",\"method\":\"notify\",\"message\":\"Model fallback: persistent state cleared; no remembered original model.\",\"notifyType\":\"info\"}}",
|
|
77
|
+
"passed": true
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "clean_shutdown",
|
|
81
|
+
"expected": "Both actual Pi processes exit cleanly without unhandled, extension-load, or shutdown errors",
|
|
82
|
+
"observed": "{\"first_exit_code\":0,\"second_exit_code\":0,\"first_timed_out\":false,\"second_timed_out\":false,\"first_unhandled_error\":false,\"second_unhandled_error\":false,\"first_extension_load_error\":false,\"second_extension_load_error\":false}",
|
|
83
|
+
"passed": true
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "host_shell",
|
|
89
|
+
"command": "npm run smoke:pi",
|
|
90
|
+
"exit_code": 0,
|
|
91
|
+
"passed": true,
|
|
92
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"rpc_records\":[],\"stderr\":\"\",\"error\":null}",
|
|
93
|
+
"assertions": [
|
|
94
|
+
{
|
|
95
|
+
"id": "native_shell_boundary",
|
|
96
|
+
"expected": "The native host shell launches the locally pinned Pi CLI and returns exactly 1.2.0",
|
|
97
|
+
"observed": "{\"exit_code\":0,\"output\":\"1.2.0\",\"expected_version\":\"1.2.0\",\"stderr\":\"\",\"error\":null}",
|
|
98
|
+
"passed": true
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { dirname, join, resolve } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
4
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -17,7 +16,7 @@ import {
|
|
|
17
16
|
type RuleWarning,
|
|
18
17
|
} from "../lib/config.js";
|
|
19
18
|
import { parseReasonFromErrorMessage, parseStatusFromErrorMessage } from "../lib/error-status.js";
|
|
20
|
-
import {
|
|
19
|
+
import { findActiveStateEntry, readState, updateState, upsertStateEntry, writeState, type FallbackState } from "../lib/state.js";
|
|
21
20
|
import { isRecord } from "../lib/internal.ts";
|
|
22
21
|
import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
|
|
23
22
|
|
|
@@ -98,18 +97,9 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
98
97
|
async function loadState(ctx: ExtensionContext): Promise<FallbackState | undefined> {
|
|
99
98
|
try {
|
|
100
99
|
syncPaths(ctx);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return state;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const before = validateStateShape(JSON.parse(await readFile(statePath, "utf8")));
|
|
108
|
-
const after = pruneExpiredState(before);
|
|
109
|
-
state = after;
|
|
110
|
-
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
|
111
|
-
await writeState(statePath, after);
|
|
112
|
-
}
|
|
100
|
+
// Prune the snapshot for selection, but never write a stale read back over another session.
|
|
101
|
+
// Mutating transactions prune the current on-disk state while holding the state lock.
|
|
102
|
+
state = await readState(paths.state);
|
|
113
103
|
return state;
|
|
114
104
|
} catch (error) {
|
|
115
105
|
ctx.ui.notify(`Model fallback state error: ${errorMessage(error)}`, "warning");
|
|
@@ -210,10 +200,11 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
210
200
|
if (!ok) ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
211
201
|
return false;
|
|
212
202
|
}
|
|
213
|
-
const loadedState = state ?? (await loadState(ctx)) ?? emptyState();
|
|
214
203
|
if (isCancelled()) return false;
|
|
215
|
-
state =
|
|
216
|
-
|
|
204
|
+
state = await updateState(paths.state, (current) => isCancelled() ? current : upsertStateEntry(current, {
|
|
205
|
+
source, fallback: match.fallback, status, until, createdAt: now.toISOString(), ruleName: match.rule.name,
|
|
206
|
+
}));
|
|
207
|
+
if (isCancelled()) return false;
|
|
217
208
|
ctx.ui.notify(`Model fallback: ${sourceKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
|
|
218
209
|
return true;
|
|
219
210
|
}
|
package/lib/state.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
3
6
|
import { modelRefKey, type ModelRef } from "./config.ts";
|
|
4
|
-
import { isRecord, readJsonIfExists, readModelRef, readNonEmptyString } from "./internal.ts";
|
|
7
|
+
import { isNodeError, isRecord, readJsonIfExists, readModelRef, readNonEmptyString } from "./internal.ts";
|
|
5
8
|
|
|
6
9
|
export const STATE_VERSION = 1;
|
|
10
|
+
const WINDOWS_RENAME_RETRY_MS = 1000;
|
|
11
|
+
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
7
12
|
|
|
8
13
|
export interface FallbackStateEntry {
|
|
9
14
|
source: ModelRef;
|
|
@@ -30,8 +35,75 @@ export async function readState(path: string, now = new Date()): Promise<Fallbac
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export async function writeState(path: string, state: FallbackState): Promise<void> {
|
|
38
|
+
const next = validateStateShape(state);
|
|
39
|
+
// Explicit replacement (notably reset) must also be able to repair a corrupt old file.
|
|
40
|
+
await withStateLock(path, (target, checkLock) => publishState(target, next, checkLock));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Serialize the entire read/modify/publish transaction, including across Pi processes. */
|
|
44
|
+
export async function updateState(path: string, update: (current: FallbackState) => FallbackState): Promise<FallbackState> {
|
|
45
|
+
return withStateLock(path, async (target, checkLock) => {
|
|
46
|
+
const next = validateStateShape(update(await readState(target)));
|
|
47
|
+
checkLock();
|
|
48
|
+
await publishState(target, next, checkLock);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function withStateLock<T>(path: string, operation: (target: string, checkLock: () => void) => Promise<T>): Promise<T> {
|
|
33
54
|
await mkdir(dirname(path), { recursive: true });
|
|
34
|
-
|
|
55
|
+
// Aliases of the same agent directory must share a lock (the state file may not exist yet).
|
|
56
|
+
const target = join(await realpath(dirname(path)), basename(path));
|
|
57
|
+
let compromised: Error | undefined;
|
|
58
|
+
const release = await lockfile.lock(target, {
|
|
59
|
+
realpath: false,
|
|
60
|
+
retries: { retries: 100, factor: 1, minTimeout: 20, maxTimeout: 20, randomize: false },
|
|
61
|
+
onCompromised: (error) => { compromised = error; },
|
|
62
|
+
});
|
|
63
|
+
const checkLock = () => { if (compromised) throw compromised; };
|
|
64
|
+
try {
|
|
65
|
+
checkLock();
|
|
66
|
+
return await operation(target, checkLock);
|
|
67
|
+
} finally {
|
|
68
|
+
await release();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function publishState(path: string, state: FallbackState, checkLock: () => void): Promise<void> {
|
|
73
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
74
|
+
let created = false;
|
|
75
|
+
try {
|
|
76
|
+
const file = await open(temporary, "wx", 0o600);
|
|
77
|
+
created = true;
|
|
78
|
+
try {
|
|
79
|
+
await file.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
80
|
+
await file.sync();
|
|
81
|
+
} finally {
|
|
82
|
+
await file.close();
|
|
83
|
+
}
|
|
84
|
+
// Readers see either the old complete document or the new one, never a partial write.
|
|
85
|
+
checkLock();
|
|
86
|
+
await replaceStateFile(temporary, path, checkLock);
|
|
87
|
+
} finally {
|
|
88
|
+
if (created) await rm(temporary, { force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function replaceStateFile(source: string, target: string, checkLock: () => void): Promise<void> {
|
|
93
|
+
// Windows can reject replacement while an unlocked reader briefly has the target open.
|
|
94
|
+
const deadline = Date.now() + WINDOWS_RENAME_RETRY_MS;
|
|
95
|
+
let retryDelayMs = 10;
|
|
96
|
+
for (;;) {
|
|
97
|
+
try {
|
|
98
|
+
await rename(source, target);
|
|
99
|
+
return;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (process.platform !== "win32" || !isNodeError(error) || !WINDOWS_RENAME_RETRY_CODES.has(error.code ?? "") || Date.now() >= deadline) throw error;
|
|
102
|
+
await delay(retryDelayMs);
|
|
103
|
+
checkLock();
|
|
104
|
+
retryDelayMs = Math.min(retryDelayMs + 10, 100);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
35
107
|
}
|
|
36
108
|
|
|
37
109
|
export function upsertStateEntry(state: FallbackState, entry: FallbackStateEntry): FallbackState {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-model-fallback",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
33
|
"test": "node --test tests/*.test.mjs",
|
|
34
|
-
"
|
|
34
|
+
"smoke:pi": "node scripts/pi-sdk-smoke.mjs",
|
|
35
|
+
"ci": "npm run typecheck && npm test && npm run smoke:pi && npm run pack:check",
|
|
35
36
|
"pack:check": "npm pack --dry-run"
|
|
36
37
|
},
|
|
37
38
|
"pi": {
|
|
@@ -50,13 +51,17 @@
|
|
|
50
51
|
"typebox": "*"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": "
|
|
54
|
-
"@earendil-works/pi-ai": "
|
|
55
|
-
"@earendil-works/pi-coding-agent": "
|
|
56
|
-
"@earendil-works/pi-tui": "
|
|
54
|
+
"@earendil-works/pi-agent-core": "1.2.0",
|
|
55
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
57
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
57
58
|
"@types/node": "^26.6.3",
|
|
59
|
+
"@types/proper-lockfile": "4.1.4",
|
|
58
60
|
"jiti": "^2.7.0",
|
|
59
61
|
"typebox": "latest",
|
|
60
62
|
"typescript": "^7.0.2"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"proper-lockfile": "4.1.2"
|
|
61
66
|
}
|
|
62
67
|
}
|