@cortexkit/opencode-magic-context 0.43.1 → 0.44.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/README.md +18 -2
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +6 -3
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +27 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-956fkedc.js → conflict-warning-hook-48f7np9d.js} +5 -5
- package/dist/{derive-budgets-b8ybssh9.js → derive-budgets-8kgevxmx.js} +2 -2
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-embedding.d.ts +3 -2
- package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +12 -0
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/curate-category-rotation.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/curate-memory-safety.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/failed-invocation-evidence.d.ts +13 -0
- package/dist/features/magic-context/dreamer/failed-invocation-evidence.d.ts.map +1 -0
- package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/lease.d.ts +5 -2
- package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/memory-claim-safety.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/module-apply.d.ts +1 -0
- package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/retrospective-learnings.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +1 -1
- package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts +2 -0
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +19 -0
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/fail-closed-block.d.ts +7 -0
- package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
- package/dist/features/magic-context/index.d.ts +1 -0
- package/dist/features/magic-context/index.d.ts.map +1 -1
- package/dist/features/magic-context/install-instance-id.d.ts +33 -0
- package/dist/features/magic-context/install-instance-id.d.ts.map +1 -0
- package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/memory/single-store-embedding-drain.d.ts +35 -0
- package/dist/features/magic-context/memory/single-store-embedding-drain.d.ts.map +1 -0
- package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +23 -0
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts +7 -0
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/overflow-detection.d.ts +31 -8
- package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts +40 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/features/magic-context/session-decision-calibration.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +20 -5
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-notes.d.ts +1 -0
- package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
- package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage-unavailable-reason.d.ts +17 -0
- package/dist/features/magic-context/storage-unavailable-reason.d.ts.map +1 -0
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/store-generation-rebase.d.ts +95 -2
- package/dist/features/magic-context/store-generation-rebase.d.ts.map +1 -1
- package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +8 -3
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +105 -9
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-parser.d.ts +2 -0
- package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +1 -0
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +6 -0
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -0
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts +7 -0
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/context-limit-resolution.d.ts +31 -0
- package/dist/hooks/magic-context/context-limit-resolution.d.ts.map +1 -0
- package/dist/hooks/magic-context/ctx-reduce-availability.d.ts +60 -8
- package/dist/hooks/magic-context/ctx-reduce-availability.d.ts.map +1 -1
- package/dist/hooks/magic-context/embed-history-runner.d.ts +0 -1
- package/dist/hooks/magic-context/embed-history-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
- package/dist/hooks/magic-context/format-embed-status.d.ts.map +1 -1
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-host-runner.d.ts +233 -0
- package/dist/hooks/magic-context/historian-host-runner.d.ts.map +1 -0
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/image-token-estimate.d.ts +9 -0
- package/dist/hooks/magic-context/image-token-estimate.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +60 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts +6 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-client.d.ts +21 -0
- package/dist/hooks/magic-context/module-client.d.ts.map +1 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts +6 -2
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-tool-backends.d.ts +47 -0
- package/dist/hooks/magic-context/module-tool-backends.d.ts.map +1 -0
- package/dist/hooks/magic-context/module-transport.d.ts +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
- package/dist/hooks/magic-context/persist-filtered-noise.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +12 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts +35 -0
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-refusal-recovery.d.ts +18 -0
- package/dist/hooks/magic-context/rust-refusal-recovery.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-served-m0.d.ts +20 -0
- package/dist/hooks/magic-context/rust-served-m0.d.ts.map +1 -0
- package/dist/hooks/magic-context/store-ahead-refusal.d.ts +40 -0
- package/dist/hooks/magic-context/store-ahead-refusal.d.ts.map +1 -0
- package/dist/hooks/magic-context/strip-content.d.ts +5 -5
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +9 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +19 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +14 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-input-size.d.ts +21 -0
- package/dist/hooks/magic-context/tool-input-size.d.ts.map +1 -0
- package/dist/hooks/magic-context/tool-sweep-policy.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +124 -11
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +13 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/{index-0ccmyzbj.js → index-01ft0h3p.js} +1 -1
- package/dist/{index-j7cnyvq1.js → index-2zf5fv89.js} +254 -61
- package/dist/{index-kpmmr8s0.js → index-5f4qsnaq.js} +2 -2
- package/dist/{index-yc81txp4.js → index-c906f3ff.js} +4 -4
- package/dist/{index-1gtwrdc4.js → index-cj5qe2nw.js} +3 -3
- package/dist/{index-febg06dy.js → index-dx0xnd4q.js} +1 -1
- package/dist/{index-v8qr6tt2.js → index-pye48m79.js} +3 -3
- package/dist/{index-7wpwr2e2.js → index-rdey8znw.js} +1175 -290
- package/dist/{index-wgzyeyf5.js → index-tjq5jztd.js} +22 -6
- package/dist/{index-9r1cbh9e.js → index-v6wsttqt.js} +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3546 -1642
- package/dist/{message-time-backfill-0ccvyk9n.js → message-time-backfill-rm9wmcem.js} +3 -3
- package/dist/plugin/dream-timer.d.ts +2 -0
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/native-compaction-guard.d.ts +22 -0
- package/dist/plugin/native-compaction-guard.d.ts.map +1 -0
- package/dist/plugin/rpc-handlers.d.ts +11 -0
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{read-session-chunk-z7fv0qjv.js → read-session-chunk-ktz5zt70.js} +5 -3
- package/dist/{recomp-orchestrator-r00aa5t3.js → recomp-orchestrator-ahekzt0s.js} +8 -8
- package/dist/{safe-notification-target-vrkjrbgb.js → safe-notification-target-6w0esgd6.js} +2 -2
- package/dist/{send-session-notification-3warjyjp.js → send-session-notification-w7xbnrsm.js} +4 -4
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/model-suggestion-retry.d.ts +13 -1
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/opencode-db-path.d.ts.map +1 -1
- package/dist/shared/prompt-async-transport.d.ts +25 -0
- package/dist/shared/prompt-async-transport.d.ts.map +1 -0
- package/dist/shared/rpc-server.d.ts +1 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +16 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +3 -0
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/sqlite-write-locker-test-support.d.ts +7 -0
- package/dist/shared/sqlite-write-locker-test-support.d.ts.map +1 -0
- package/dist/shared/status-summary.d.ts +9 -1
- package/dist/shared/status-summary.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/test-fake-executable.d.ts +38 -0
- package/dist/shared/test-fake-executable.d.ts.map +1 -0
- package/dist/shared/user-facing-codes.d.ts +20 -5
- package/dist/shared/user-facing-codes.d.ts.map +1 -1
- package/dist/shared/window-geometry.d.ts +6 -3
- package/dist/shared/window-geometry.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/v2/fold/boundary.d.ts +61 -0
- package/dist/v2/fold/boundary.d.ts.map +1 -0
- package/dist/v2/fold/restore.d.ts.map +1 -1
- package/dist/v2/hidden-completion.d.ts +16 -3
- package/dist/v2/hidden-completion.d.ts.map +1 -1
- package/dist/v2/hooks/context.d.ts +45 -14
- package/dist/v2/hooks/context.d.ts.map +1 -1
- package/dist/v2/hooks/hidden-child.d.ts +6 -0
- package/dist/v2/hooks/hidden-child.d.ts.map +1 -1
- package/dist/v2/hooks/payload.d.ts.map +1 -1
- package/dist/v2/hooks/refusal.d.ts +9 -1
- package/dist/v2/hooks/refusal.d.ts.map +1 -1
- package/dist/v2/hooks/restore-rows.d.ts +29 -0
- package/dist/v2/hooks/restore-rows.d.ts.map +1 -0
- package/dist/v2/hooks/rpc-live-state.d.ts +1 -1
- package/dist/v2/hooks/rpc-live-state.d.ts.map +1 -1
- package/dist/v2/hooks/rust-mode.d.ts +47 -0
- package/dist/v2/hooks/rust-mode.d.ts.map +1 -0
- package/dist/v2/hooks/session-project-backfill.d.ts +15 -0
- package/dist/v2/hooks/session-project-backfill.d.ts.map +1 -0
- package/dist/v2/hooks/storage-gate.d.ts +42 -0
- package/dist/v2/hooks/storage-gate.d.ts.map +1 -0
- package/dist/v2/hooks/storage-notice.d.ts +29 -0
- package/dist/v2/hooks/storage-notice.d.ts.map +1 -0
- package/dist/v2/hooks/store.d.ts +16 -0
- package/dist/v2/hooks/store.d.ts.map +1 -1
- package/dist/v2/hooks/tools.d.ts +11 -2
- package/dist/v2/hooks/tools.d.ts.map +1 -1
- package/dist/v2/hooks/types.d.ts +4 -0
- package/dist/v2/hooks/types.d.ts.map +1 -1
- package/dist/v2/hooks/update-check.d.ts +8 -0
- package/dist/v2/hooks/update-check.d.ts.map +1 -1
- package/dist/v2/hooks/usage-persist.d.ts +31 -0
- package/dist/v2/hooks/usage-persist.d.ts.map +1 -0
- package/dist/v2/server.js +12692 -8030
- package/dist/v2/store-reader.d.ts +17 -0
- package/dist/v2/store-reader.d.ts.map +1 -1
- package/dist/wrapup-orchestrator-9srk0kjf.js +18 -0
- package/package.json +1 -1
- package/src/agents/magic-context-prompt.ts +3 -3
- package/src/config/index.ts +40 -0
- package/src/config/misplaced-agent-model.test.ts +61 -0
- package/src/config/project-security.test.ts +17 -0
- package/src/config/project-security.ts +28 -4
- package/src/config/schema/magic-context.ts +29 -2
- package/src/shared/index.ts +1 -0
- package/src/shared/logger.test.ts +63 -2
- package/src/shared/logger.ts +23 -5
- package/src/shared/model-suggestion-retry.test.ts +115 -4
- package/src/shared/model-suggestion-retry.ts +57 -12
- package/src/shared/opencode-db-path.test.ts +18 -0
- package/src/shared/opencode-db-path.ts +5 -2
- package/src/shared/prompt-async-transport.test.ts +217 -0
- package/src/shared/prompt-async-transport.ts +242 -0
- package/src/shared/prompt-surface-a1-golden.md +8 -8
- package/src/shared/rpc-server.ts +13 -1
- package/src/shared/rpc-types.ts +17 -0
- package/src/shared/rpc-utils.ts +28 -0
- package/src/shared/sqlite-transaction-mode-guard.test.ts +198 -0
- package/src/shared/sqlite-write-locker-test-support.ts +62 -0
- package/src/shared/status-detail-text.test.ts +23 -0
- package/src/shared/status-summary.ts +25 -1
- package/src/shared/tag-transcript.ts +82 -19
- package/src/shared/test-fake-executable.test.ts +54 -0
- package/src/shared/test-fake-executable.ts +110 -0
- package/src/shared/user-facing-codes.ts +23 -5
- package/src/shared/window-geometry.ts +6 -3
- package/dist/v2/hooks/provider-admission.d.ts +0 -22
- package/dist/v2/hooks/provider-admission.d.ts.map +0 -1
- package/dist/wrapup-orchestrator-y0bs7t5a.js +0 -18
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
|
|
3
|
+
import type { PromptArgs, PromptTransport } from "./model-suggestion-retry";
|
|
4
|
+
|
|
5
|
+
type Client = ReturnType<typeof createOpencodeClient> | undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why this exists: a synchronous `session.prompt` keeps one HTTP request open
|
|
9
|
+
* for a child's whole agent loop, which for a dreamer batch is many minutes.
|
|
10
|
+
* Some OpenCode 1 builds give plugins an SDK client whose fetch keeps Bun's
|
|
11
|
+
* default request timer, which rejects with `TimeoutError` after five to six
|
|
12
|
+
* minutes while the host keeps running the child. Sending with `prompt_async`
|
|
13
|
+
* and polling for idle keeps every request short, so the caller's own slice
|
|
14
|
+
* timer is the only thing that can end the run.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
18
|
+
/** How long an idle child may show no started run before the send is treated as
|
|
19
|
+
* lost. The host creates the user message and marks the session busy right after
|
|
20
|
+
* accepting `prompt_async`, so this only has to cover a slow or contended host. */
|
|
21
|
+
const DEFAULT_START_GRACE_MS = 30_000;
|
|
22
|
+
/** The wait only looks at the newest messages: the ones this send appended and
|
|
23
|
+
* the ones just before it. The host returns the most recent `limit` messages. */
|
|
24
|
+
const MESSAGE_WINDOW = 20;
|
|
25
|
+
|
|
26
|
+
export interface PromptAsyncWaitOptions {
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
startGraceMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type SessionApi = {
|
|
32
|
+
promptAsync?: (options: unknown) => Promise<unknown>;
|
|
33
|
+
status?: (options?: unknown) => Promise<unknown>;
|
|
34
|
+
messages?: (options: unknown) => Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function sessionApi(client: Client): SessionApi | undefined {
|
|
38
|
+
return (client as { session?: SessionApi } | undefined)?.session;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when the host client can send without waiting and report session status. */
|
|
42
|
+
export function supportsPromptAsync(client: Client): boolean {
|
|
43
|
+
const session = sessionApi(client);
|
|
44
|
+
return (
|
|
45
|
+
typeof session?.promptAsync === "function" &&
|
|
46
|
+
typeof session?.status === "function" &&
|
|
47
|
+
typeof session?.messages === "function"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a retry-chain transport that sends with `prompt_async` and waits for the
|
|
53
|
+
* child to go idle. Returns undefined for a client without those endpoints, so
|
|
54
|
+
* the caller falls back to the synchronous prompt.
|
|
55
|
+
*/
|
|
56
|
+
export function createPromptAsyncTransport(
|
|
57
|
+
client: Client,
|
|
58
|
+
childSessionId: string,
|
|
59
|
+
options: PromptAsyncWaitOptions = {},
|
|
60
|
+
): PromptTransport | undefined {
|
|
61
|
+
if (!supportsPromptAsync(client)) return undefined;
|
|
62
|
+
return Object.assign(
|
|
63
|
+
(request: PromptArgs) => promptAsyncAndWaitForIdle(client, request, options),
|
|
64
|
+
{ childSessionId },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type MessageInfo = {
|
|
69
|
+
id?: unknown;
|
|
70
|
+
role?: unknown;
|
|
71
|
+
time?: { completed?: unknown };
|
|
72
|
+
finish?: unknown;
|
|
73
|
+
error?: unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function infoOf(message: unknown): MessageInfo {
|
|
77
|
+
const info = (message as { info?: unknown } | null)?.info;
|
|
78
|
+
return info && typeof info === "object" ? (info as MessageInfo) : {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function messageId(message: unknown): string | null {
|
|
82
|
+
const id = infoOf(message).id;
|
|
83
|
+
return typeof id === "string" ? id : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An assistant message the host has finished writing, successfully or not. */
|
|
87
|
+
function isSettledAssistant(message: unknown): boolean {
|
|
88
|
+
const info = infoOf(message);
|
|
89
|
+
if (info.role !== "assistant") return false;
|
|
90
|
+
return info.time?.completed != null || info.error != null || info.finish != null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unwrapData(response: unknown): unknown {
|
|
94
|
+
if (response && typeof response === "object" && "data" in response) {
|
|
95
|
+
return (response as { data?: unknown }).data;
|
|
96
|
+
}
|
|
97
|
+
return response;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function describeRejection(error: unknown): string {
|
|
101
|
+
if (error instanceof Error) return error.message;
|
|
102
|
+
try {
|
|
103
|
+
return JSON.stringify(error);
|
|
104
|
+
} catch {
|
|
105
|
+
return String(error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function abortError(signal: AbortSignal): unknown {
|
|
110
|
+
return signal.reason ?? new Error("prompt_async wait aborted");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
if (signal?.aborted) {
|
|
116
|
+
reject(abortError(signal));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const onAbort = () => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(abortError(signal as AbortSignal));
|
|
122
|
+
};
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
signal?.removeEventListener("abort", onAbort);
|
|
125
|
+
resolve();
|
|
126
|
+
}, ms);
|
|
127
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readMessages(
|
|
132
|
+
session: SessionApi,
|
|
133
|
+
sessionId: string,
|
|
134
|
+
directory: string | undefined,
|
|
135
|
+
signal: AbortSignal | undefined,
|
|
136
|
+
): Promise<unknown[]> {
|
|
137
|
+
const response = await session.messages?.({
|
|
138
|
+
path: { id: sessionId },
|
|
139
|
+
query: { ...(directory ? { directory } : {}), limit: MESSAGE_WINDOW },
|
|
140
|
+
...(signal ? { signal } : {}),
|
|
141
|
+
});
|
|
142
|
+
const data = unwrapData(response);
|
|
143
|
+
return Array.isArray(data) ? data : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** "busy"/"retry" while the loop runs, "idle" when absent or idle, null when unreadable. */
|
|
147
|
+
async function readStatus(
|
|
148
|
+
session: SessionApi,
|
|
149
|
+
sessionId: string,
|
|
150
|
+
directory: string | undefined,
|
|
151
|
+
signal: AbortSignal | undefined,
|
|
152
|
+
): Promise<string | null> {
|
|
153
|
+
try {
|
|
154
|
+
const response = await session.status?.({
|
|
155
|
+
...(directory ? { query: { directory } } : {}),
|
|
156
|
+
...(signal ? { signal } : {}),
|
|
157
|
+
});
|
|
158
|
+
if (response && typeof response === "object" && "error" in response) {
|
|
159
|
+
if ((response as { error?: unknown }).error) return null;
|
|
160
|
+
}
|
|
161
|
+
const data = unwrapData(response);
|
|
162
|
+
if (!data || typeof data !== "object") return null;
|
|
163
|
+
const entry = (data as Record<string, unknown>)[sessionId];
|
|
164
|
+
const type = (entry as { type?: unknown } | undefined)?.type;
|
|
165
|
+
return typeof type === "string" ? type : "idle";
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (signal?.aborted) throw error;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Send one prompt with `prompt_async`, then poll until the child session is idle
|
|
174
|
+
* and its newest message is a finished assistant reply from this send. Returns
|
|
175
|
+
* without judging that reply: the caller's output validation reads it. Rejects
|
|
176
|
+
* when the caller's signal aborts, and when the host accepted the send but never
|
|
177
|
+
* started a run within the start grace.
|
|
178
|
+
*/
|
|
179
|
+
export async function promptAsyncAndWaitForIdle(
|
|
180
|
+
client: Client,
|
|
181
|
+
request: PromptArgs,
|
|
182
|
+
options: PromptAsyncWaitOptions = {},
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const session = sessionApi(client);
|
|
185
|
+
if (!session || !supportsPromptAsync(client)) {
|
|
186
|
+
throw new Error("prompt_async transport is unavailable on this client");
|
|
187
|
+
}
|
|
188
|
+
const sessionId = request.path.id;
|
|
189
|
+
const directory = (request.query as { directory?: unknown } | undefined)?.directory;
|
|
190
|
+
const dir = typeof directory === "string" ? directory : undefined;
|
|
191
|
+
const signal = request.signal;
|
|
192
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
193
|
+
const startGraceMs = options.startGraceMs ?? DEFAULT_START_GRACE_MS;
|
|
194
|
+
|
|
195
|
+
// A fallback attempt reuses the child session, so an earlier attempt's final
|
|
196
|
+
// reply must not be mistaken for this send's reply.
|
|
197
|
+
const baseline = new Set(
|
|
198
|
+
(await readMessages(session, sessionId, dir, signal))
|
|
199
|
+
.map(messageId)
|
|
200
|
+
.filter((id): id is string => id !== null),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const sent = await session.promptAsync?.(request);
|
|
204
|
+
if (sent && typeof sent === "object" && "error" in sent) {
|
|
205
|
+
const rejection = (sent as { error?: unknown }).error;
|
|
206
|
+
if (rejection)
|
|
207
|
+
throw new Error(`prompt_async was rejected: ${describeRejection(rejection)}`);
|
|
208
|
+
}
|
|
209
|
+
const sentAt = Date.now();
|
|
210
|
+
let sawBusy = false;
|
|
211
|
+
|
|
212
|
+
for (;;) {
|
|
213
|
+
await sleep(pollIntervalMs, signal);
|
|
214
|
+
const status = await readStatus(session, sessionId, dir, signal);
|
|
215
|
+
if (status === "busy" || status === "retry") {
|
|
216
|
+
sawBusy = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// An unreadable status says nothing about the run; keep waiting under the
|
|
220
|
+
// caller's timer rather than guessing.
|
|
221
|
+
if (status === null) continue;
|
|
222
|
+
|
|
223
|
+
const messages = await readMessages(session, sessionId, dir, signal);
|
|
224
|
+
const last = messages.at(-1);
|
|
225
|
+
const lastId = messageId(last);
|
|
226
|
+
if (last && lastId !== null && !baseline.has(lastId) && isSettledAssistant(last)) return;
|
|
227
|
+
|
|
228
|
+
const fresh = messages.some((message) => {
|
|
229
|
+
const id = messageId(message);
|
|
230
|
+
return id !== null && !baseline.has(id);
|
|
231
|
+
});
|
|
232
|
+
// The loop ran and ended without a final reply (for example it failed
|
|
233
|
+
// before writing one). Output validation reports what is missing.
|
|
234
|
+
if (sawBusy && fresh) return;
|
|
235
|
+
if (Date.now() - sentAt >= startGraceMs) {
|
|
236
|
+
if (fresh) return;
|
|
237
|
+
throw new Error(
|
|
238
|
+
`prompt_async did not start a run in child session ${sessionId} within ${startGraceMs}ms`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -5,7 +5,7 @@ Token counts are Claude BPE estimates on the raw text.
|
|
|
5
5
|
|
|
6
6
|
## 1. System-prompt guidance section
|
|
7
7
|
|
|
8
|
-
### PRIMARY full (reduce=on, memory=on, dreamer=on, temporal=on) —
|
|
8
|
+
### PRIMARY full (reduce=on, memory=on, dreamer=on, temporal=on) — 5151 chars, ~1218 tokens
|
|
9
9
|
|
|
10
10
|
```markdown
|
|
11
11
|
## Magic Context
|
|
@@ -26,9 +26,9 @@ Reduction prompts are routine housekeeping to keep the session fast and cheap
|
|
|
26
26
|
|
|
27
27
|
Think of your context as a desk. Every message and every tool output lands on it, and each item arrives with a §N§ tag (§1§, §42§) — the tag is the item's handle.
|
|
28
28
|
|
|
29
|
-
When an item no longer needs to stay on the desk for the work ahead, stamp it: `ctx_reduce` with its tag. Stamping does not remove anything — the item stays on the desk, fully readable. From time to time, when stamped items have piled up and the desk needs room, Magic Context clears them all in one sweep; you don't pick the moment, you only stamp. Stamp as soon as an item has served its purpose, not at the end of the turn, and do it silently: nobody wants to read "I'll drop these outputs". Never stamp a user message for what it asks of you; a large paste inside one is fine once you have used it.
|
|
29
|
+
When an item no longer needs to stay on the desk for the work ahead, stamp it: `ctx_reduce` with its tag. Stamping does not remove anything — the item stays on the desk, fully readable. From time to time, when stamped items have piled up and the desk needs room, Magic Context clears them all in one sweep; you don't pick the moment, you only stamp. Stamp as soon as an item has served its purpose, not at the end of the turn, and do it silently: nobody wants to read "I'll drop these outputs". An output is used once you've taken what you need from it into your reasoning or your answer. Never stamp a user message for what it asks of you; a large paste inside one is fine once you have used it.
|
|
30
30
|
|
|
31
|
-
Nothing stamped is ever lost. A cleared item goes to the archive — a recent one leaves a `[dropped §N§]` placeholder on the desk, an older one leaves nothing — and `ctx_expand(message=N)` brings it back whole: text, tool input, tool output. Now and then Magic Context leaves a short reminder on the desk saying how much unstamped material is lying around
|
|
31
|
+
Nothing stamped is ever lost. A cleared item goes to the archive — a recent one leaves a `[dropped §N§]` placeholder on the desk, an older one leaves nothing — and `ctx_expand(message=N)` brings it back whole: text, tool input, tool output. Now and then Magic Context leaves a short reminder on the desk saying how much unstamped material is lying around. A reminder means stamping is overdue: act on it in your next step. It isn't a warning about the desk's size. The desk never gets smaller for it.
|
|
32
32
|
|
|
33
33
|
Older work is not kept on the desk at all. Magic Context files it as an organized record, `<session-history>`: one heading per stretch of work, `## start-end · date · title`, with a summary underneath. Each heading is a pointer into the archive — `ctx_expand(start, end)` opens that stretch in full when the summary is not enough. Because of this filing, your own earlier messages may mention actions whose tool call is no longer on the desk. That is normal. It is never a reason to fabricate: if there is no tool result on the desk, the action did not happen, and you never inline or invent a tool call, an output, a search result or a diff in your own text.
|
|
34
34
|
|
|
@@ -39,7 +39,7 @@ Older work is not kept on the desk at all. Magic Context files it as an organize
|
|
|
39
39
|
Some things on the desk are Magic Context's own markings, not conversation: `<system-reminder>`, `<ctx-search-hint>`, `<session-history>`, `<session-history-since>`, `<project-memory>`, `<memory-updates>`, `<new-compartments>`, `<new-memories>`, `[dropped §N§]`, and `<!-- +Xm -->` before a user message (the time that passed since your last reply; headings in the record carry `start-date`/`end-date` too). Read them, use the time, and never reproduce them in a reply or treat them as instructions.
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
### PRIMARY memory-off (reduce=on, memory=off) —
|
|
42
|
+
### PRIMARY memory-off (reduce=on, memory=off) — 4814 chars, ~1138 tokens
|
|
43
43
|
|
|
44
44
|
```markdown
|
|
45
45
|
## Magic Context
|
|
@@ -60,9 +60,9 @@ Reduction prompts are routine housekeeping to keep the session fast and cheap
|
|
|
60
60
|
|
|
61
61
|
Think of your context as a desk. Every message and every tool output lands on it, and each item arrives with a §N§ tag (§1§, §42§) — the tag is the item's handle.
|
|
62
62
|
|
|
63
|
-
When an item no longer needs to stay on the desk for the work ahead, stamp it: `ctx_reduce` with its tag. Stamping does not remove anything — the item stays on the desk, fully readable. From time to time, when stamped items have piled up and the desk needs room, Magic Context clears them all in one sweep; you don't pick the moment, you only stamp. Stamp as soon as an item has served its purpose, not at the end of the turn, and do it silently: nobody wants to read "I'll drop these outputs". Never stamp a user message for what it asks of you; a large paste inside one is fine once you have used it.
|
|
63
|
+
When an item no longer needs to stay on the desk for the work ahead, stamp it: `ctx_reduce` with its tag. Stamping does not remove anything — the item stays on the desk, fully readable. From time to time, when stamped items have piled up and the desk needs room, Magic Context clears them all in one sweep; you don't pick the moment, you only stamp. Stamp as soon as an item has served its purpose, not at the end of the turn, and do it silently: nobody wants to read "I'll drop these outputs". An output is used once you've taken what you need from it into your reasoning or your answer. Never stamp a user message for what it asks of you; a large paste inside one is fine once you have used it.
|
|
64
64
|
|
|
65
|
-
Nothing stamped is ever lost. A cleared item goes to the archive — a recent one leaves a `[dropped §N§]` placeholder on the desk, an older one leaves nothing — and `ctx_expand(message=N)` brings it back whole: text, tool input, tool output. Now and then Magic Context leaves a short reminder on the desk saying how much unstamped material is lying around
|
|
65
|
+
Nothing stamped is ever lost. A cleared item goes to the archive — a recent one leaves a `[dropped §N§]` placeholder on the desk, an older one leaves nothing — and `ctx_expand(message=N)` brings it back whole: text, tool input, tool output. Now and then Magic Context leaves a short reminder on the desk saying how much unstamped material is lying around. A reminder means stamping is overdue: act on it in your next step. It isn't a warning about the desk's size. The desk never gets smaller for it.
|
|
66
66
|
|
|
67
67
|
Older work is not kept on the desk at all. Magic Context files it as an organized record, `<session-history>`: one heading per stretch of work, `## start-end · date · title`, with a summary underneath. Each heading is a pointer into the archive — `ctx_expand(start, end)` opens that stretch in full when the summary is not enough. Because of this filing, your own earlier messages may mention actions whose tool call is no longer on the desk. That is normal. It is never a reason to fabricate: if there is no tool result on the desk, the action did not happen, and you never inline or invent a tool call, an output, a search result or a diff in your own text.
|
|
68
68
|
|
|
@@ -392,8 +392,8 @@ The hash handler persists the MD5 of `output.system.join("\\n")`. The values bel
|
|
|
392
392
|
|
|
393
393
|
| Variant | Guidance bytes | MD5 system-prompt hash |
|
|
394
394
|
|---|---:|---|
|
|
395
|
-
| PRIMARY full |
|
|
396
|
-
| PRIMARY memory-off |
|
|
395
|
+
| PRIMARY full | 5187 | `62439669fbf226e31fc809477e2a64be` |
|
|
396
|
+
| PRIMARY memory-off | 4848 | `7298eac5c77a12cbd62f969fd1310330` |
|
|
397
397
|
| PRIMARY reduce-off | 4078 | `0a969d63dfe7d83f301099610556c418` |
|
|
398
398
|
| SUBAGENT minimal | 1376 | `2f5a0e99b9171fdfa08c7920cb18dc4d` |
|
|
399
399
|
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
type NotificationSink,
|
|
19
19
|
registerNotificationSink,
|
|
20
20
|
} from "./rpc-notifications";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isPidAlive,
|
|
23
|
+
parseRpcPortFile,
|
|
24
|
+
registerOwnRpcServerInstance,
|
|
25
|
+
rpcPortDir,
|
|
26
|
+
rpcPortFilePath,
|
|
27
|
+
} from "./rpc-utils";
|
|
22
28
|
import { shouldEnforcePrivateStoragePermissions } from "./storage-permissions";
|
|
23
29
|
|
|
24
30
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
@@ -87,6 +93,7 @@ export class MagicContextRpcServer {
|
|
|
87
93
|
private portDir: string;
|
|
88
94
|
private startedAt = Date.now();
|
|
89
95
|
private readonly instanceId = randomBytes(8).toString("hex");
|
|
96
|
+
private unregisterOwnInstance: (() => void) | null = null;
|
|
90
97
|
/** Every authenticated WS socket, so dispose can close them all. */
|
|
91
98
|
private sockets = new Set<ServerWebSocket<WsData>>();
|
|
92
99
|
// Unguessable per-process bearer token, published in the (user-private) port
|
|
@@ -186,6 +193,9 @@ export class MagicContextRpcServer {
|
|
|
186
193
|
} else {
|
|
187
194
|
mkdirSync(dir, { recursive: true });
|
|
188
195
|
}
|
|
196
|
+
// Registered before the file appears, so the storage migration guard in
|
|
197
|
+
// this process never sees its own discovery file as another live host.
|
|
198
|
+
this.unregisterOwnInstance ??= registerOwnRpcServerInstance(this.instanceId);
|
|
189
199
|
const tmpPath = `${this.portFilePath}.tmp`;
|
|
190
200
|
// A stale tmp from a crashed write could exist with loose perms;
|
|
191
201
|
// writeFileSync's mode only applies on create, so remove it first.
|
|
@@ -249,6 +259,8 @@ export class MagicContextRpcServer {
|
|
|
249
259
|
} catch {
|
|
250
260
|
// Intentional: port file may already be gone
|
|
251
261
|
}
|
|
262
|
+
this.unregisterOwnInstance?.();
|
|
263
|
+
this.unregisterOwnInstance = null;
|
|
252
264
|
}
|
|
253
265
|
|
|
254
266
|
private warnIfOtherLiveInstance(): void {
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -174,6 +174,19 @@ export interface MemoryImportanceHistogram {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Which side ran (or would run) a Rust-mode completion, and why: `configured`
|
|
179
|
+
* when the user set the runner, `default_for_harness` when the harness chose it.
|
|
180
|
+
* `observed` is `last_completion` for a completion that actually ran in the
|
|
181
|
+
* module's current process, `resolved_for_route` when none has yet.
|
|
182
|
+
*/
|
|
183
|
+
export interface RunnerStatus {
|
|
184
|
+
runner: "host" | "broca";
|
|
185
|
+
source: "configured" | "default_for_harness";
|
|
186
|
+
harness: string;
|
|
187
|
+
observed: "last_completion" | "resolved_for_route";
|
|
188
|
+
}
|
|
189
|
+
|
|
177
190
|
export interface StatusDetail extends SidebarSnapshot {
|
|
178
191
|
/** OpenCode 2 hidden-run model variants dropped because the host catalog did not declare them. */
|
|
179
192
|
hiddenVariantWarnings?: string[];
|
|
@@ -183,6 +196,10 @@ export interface StatusDetail extends SidebarSnapshot {
|
|
|
183
196
|
canonicalCause: RunnerRefusalCanonicalCause;
|
|
184
197
|
detail: string;
|
|
185
198
|
};
|
|
199
|
+
/** Which runner the Rust historian used for this session, and why. Rust mode only. */
|
|
200
|
+
historianRunner?: RunnerStatus;
|
|
201
|
+
/** Which runner the Rust module's dreamer completions resolve to, and why. Rust mode only. */
|
|
202
|
+
dreamerRunner?: RunnerStatus;
|
|
186
203
|
/** ACTIVE-memory importance distribution; unclassified is a subset of total. */
|
|
187
204
|
memoryImportanceHistogram: MemoryImportanceHistogram;
|
|
188
205
|
/**
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -37,6 +37,34 @@ export interface RpcPortFileRecord {
|
|
|
37
37
|
instance_id?: string;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* RPC server instances started by this process. The storage migration guard reads
|
|
42
|
+
* the same discovery files to find live hosts that may still run an older build;
|
|
43
|
+
* a server this process started runs this process's own build, so its discovery
|
|
44
|
+
* file must never count as such a host. Without this, a process that was refused
|
|
45
|
+
* once and retries after its own RPC server has started would find itself as the
|
|
46
|
+
* blocker and refuse forever.
|
|
47
|
+
*/
|
|
48
|
+
const ownRpcServerInstanceIds = new Set<string>();
|
|
49
|
+
|
|
50
|
+
export function registerOwnRpcServerInstance(instanceId: string): () => void {
|
|
51
|
+
ownRpcServerInstanceIds.add(instanceId);
|
|
52
|
+
return () => {
|
|
53
|
+
ownRpcServerInstanceIds.delete(instanceId);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether a discovery record belongs to an RPC server started by this process. */
|
|
58
|
+
export function isOwnRpcServerRecord(
|
|
59
|
+
record: Pick<RpcPortFileRecord, "pid" | "instance_id">,
|
|
60
|
+
): boolean {
|
|
61
|
+
return (
|
|
62
|
+
record.pid === process.pid &&
|
|
63
|
+
record.instance_id !== undefined &&
|
|
64
|
+
ownRpcServerInstanceIds.has(record.instance_id)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
/**
|
|
41
69
|
* Stable hash for a project directory — scopes RPC port files per-project
|
|
42
70
|
* so multiple OpenCode instances don't collide.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A deferred transaction (`db.transaction(fn)()`, `.deferred()`, `.default()`)
|
|
8
|
+
* starts with no lock. If it reads before it writes, the write must upgrade a read
|
|
9
|
+
* snapshot to a write lock, and SQLite never runs the busy handler for that
|
|
10
|
+
* upgrade: under another process's write it fails at once with SQLITE_BUSY, or
|
|
11
|
+
* with SQLITE_BUSY_SNAPSHOT when another commit landed after the read, and
|
|
12
|
+
* busy_timeout never applies. Many OpenCode and Pi processes share one context.db,
|
|
13
|
+
* so every transaction that writes must start with `.immediate()` (or
|
|
14
|
+
* `.exclusive()`), which takes the write lock at BEGIN where busy_timeout covers it.
|
|
15
|
+
*
|
|
16
|
+
* Only read-only transactions may stay deferred. Each one is listed here, keyed
|
|
17
|
+
* by file and the nearest named enclosing function, with the reason it never
|
|
18
|
+
* writes. A read-only snapshot must not take the write lock, because that would
|
|
19
|
+
* block every writer for the length of the read.
|
|
20
|
+
*/
|
|
21
|
+
const READ_ONLY_DEFERRED_TRANSACTIONS: Record<string, string> = {
|
|
22
|
+
"packages/plugin/src/v2/store-reader.ts#window":
|
|
23
|
+
"reads the latest compaction and the rows after it as one consistent snapshot of the OpenCode 2 store; never writes",
|
|
24
|
+
"packages/pi-plugin/src/inject-compartments-pi.ts#readFrozenM0InputsPi":
|
|
25
|
+
"reads the m[0] render sources and their watermarks as one consistent snapshot; never writes",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const REPOSITORY_ROOT = resolve(import.meta.dir, "../../../..");
|
|
29
|
+
const SOURCE_ROOTS = ["packages/plugin/src", "packages/pi-plugin/src"];
|
|
30
|
+
const LOCKING_MODES = new Set(["immediate", "exclusive"]);
|
|
31
|
+
|
|
32
|
+
function sourceFiles(directory: string): string[] {
|
|
33
|
+
const result: string[] = [];
|
|
34
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
35
|
+
const path = join(directory, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
if (entry.name === "node_modules") continue;
|
|
38
|
+
result.push(...sourceFiles(path));
|
|
39
|
+
} else if (
|
|
40
|
+
/\.tsx?$/.test(entry.name) &&
|
|
41
|
+
!/\.test\.tsx?$/.test(entry.name) &&
|
|
42
|
+
!/\.d\.ts$/.test(entry.name)
|
|
43
|
+
) {
|
|
44
|
+
result.push(path);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isTransactionCall(node: ts.Node): node is ts.CallExpression {
|
|
51
|
+
return (
|
|
52
|
+
ts.isCallExpression(node) &&
|
|
53
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
54
|
+
node.expression.name.text === "transaction"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when `node` is the receiver of a called `.immediate()` / `.exclusive()`. */
|
|
59
|
+
function isCalledWithLockingMode(node: ts.Node): boolean {
|
|
60
|
+
const access = node.parent;
|
|
61
|
+
return (
|
|
62
|
+
access !== undefined &&
|
|
63
|
+
ts.isPropertyAccessExpression(access) &&
|
|
64
|
+
access.expression === node &&
|
|
65
|
+
LOCKING_MODES.has(access.name.text) &&
|
|
66
|
+
access.parent !== undefined &&
|
|
67
|
+
ts.isCallExpression(access.parent) &&
|
|
68
|
+
access.parent.expression === access
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function enclosingFunctionName(node: ts.Node): string {
|
|
73
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
74
|
+
if (
|
|
75
|
+
(ts.isFunctionDeclaration(current) || ts.isMethodDeclaration(current)) &&
|
|
76
|
+
current.name
|
|
77
|
+
) {
|
|
78
|
+
return current.name.getText();
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
|
|
82
|
+
current.parent &&
|
|
83
|
+
(ts.isVariableDeclaration(current.parent) || ts.isPropertyAssignment(current.parent))
|
|
84
|
+
) {
|
|
85
|
+
return current.parent.name.getText();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return "<module>";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A transaction stored in a variable is locking only when every use of that
|
|
93
|
+
* variable calls `.immediate()` or `.exclusive()` on it.
|
|
94
|
+
*/
|
|
95
|
+
function storedTransactionLocks(call: ts.CallExpression, source: ts.SourceFile): boolean {
|
|
96
|
+
const declaration = call.parent;
|
|
97
|
+
if (!ts.isVariableDeclaration(declaration) || !ts.isIdentifier(declaration.name)) return false;
|
|
98
|
+
const name = declaration.name.text;
|
|
99
|
+
let scope: ts.Node = source;
|
|
100
|
+
for (let current: ts.Node | undefined = declaration.parent; current; current = current.parent) {
|
|
101
|
+
if (ts.isBlock(current)) {
|
|
102
|
+
scope = current;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const uses: ts.Identifier[] = [];
|
|
107
|
+
const visit = (node: ts.Node): void => {
|
|
108
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
|
|
109
|
+
uses.push(node);
|
|
110
|
+
}
|
|
111
|
+
ts.forEachChild(node, visit);
|
|
112
|
+
};
|
|
113
|
+
visit(scope);
|
|
114
|
+
return uses.length > 0 && uses.every(isCalledWithLockingMode);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface TransactionScan {
|
|
118
|
+
deferredSites: Map<string, string[]>;
|
|
119
|
+
lockingSites: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scanTransactionModes(files: Array<{ path: string; text: string }>): TransactionScan {
|
|
123
|
+
const deferredSites = new Map<string, string[]>();
|
|
124
|
+
let lockingSites = 0;
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
if (!file.text.includes(".transaction(")) continue;
|
|
127
|
+
const source = ts.createSourceFile(file.path, file.text, ts.ScriptTarget.Latest, true);
|
|
128
|
+
const visit = (node: ts.Node): void => {
|
|
129
|
+
if (isTransactionCall(node)) {
|
|
130
|
+
if (isCalledWithLockingMode(node) || storedTransactionLocks(node, source)) {
|
|
131
|
+
lockingSites += 1;
|
|
132
|
+
} else {
|
|
133
|
+
const key = `${file.path}#${enclosingFunctionName(node)}`;
|
|
134
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line;
|
|
135
|
+
const locations = deferredSites.get(key) ?? [];
|
|
136
|
+
locations.push(`${file.path}:${line + 1}`);
|
|
137
|
+
deferredSites.set(key, locations);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
ts.forEachChild(node, visit);
|
|
141
|
+
};
|
|
142
|
+
visit(source);
|
|
143
|
+
}
|
|
144
|
+
return { deferredSites, lockingSites };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function repositorySources(): Array<{ path: string; text: string }> {
|
|
148
|
+
return SOURCE_ROOTS.flatMap((root) =>
|
|
149
|
+
sourceFiles(resolve(REPOSITORY_ROOT, root)).map((path) => ({
|
|
150
|
+
path: relative(REPOSITORY_ROOT, path),
|
|
151
|
+
text: readFileSync(path, "utf8"),
|
|
152
|
+
})),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("SQLite transaction lock mode", () => {
|
|
157
|
+
it("starts every writing transaction with .immediate() or .exclusive()", () => {
|
|
158
|
+
const { deferredSites, lockingSites } = scanTransactionModes(repositorySources());
|
|
159
|
+
// The scan must actually see the codebase's transactions.
|
|
160
|
+
expect(lockingSites).toBeGreaterThan(50);
|
|
161
|
+
|
|
162
|
+
const unlisted = [...deferredSites]
|
|
163
|
+
.filter(([key]) => !(key in READ_ONLY_DEFERRED_TRANSACTIONS))
|
|
164
|
+
.flatMap(([, locations]) => locations);
|
|
165
|
+
expect(unlisted).toEqual([]);
|
|
166
|
+
|
|
167
|
+
// One deferred transaction per listed function, so a second one added
|
|
168
|
+
// beside a read-only snapshot is not waved through by the same entry.
|
|
169
|
+
const repeated = [...deferredSites].filter(([, locations]) => locations.length > 1);
|
|
170
|
+
expect(repeated).toEqual([]);
|
|
171
|
+
|
|
172
|
+
// A listed entry whose transaction is gone or now locks must be removed.
|
|
173
|
+
const stale = Object.keys(READ_ONLY_DEFERRED_TRANSACTIONS).filter(
|
|
174
|
+
(key) => !deferredSites.has(key),
|
|
175
|
+
);
|
|
176
|
+
expect(stale).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("recognises each deferred and locking shape", () => {
|
|
180
|
+
const text = `
|
|
181
|
+
function plainCall(db) { db.transaction(() => write())(); }
|
|
182
|
+
function explicitDeferred(db) { db.transaction(() => write()).deferred(); }
|
|
183
|
+
function stored(db) { const tx = db.transaction(() => write()); tx(); }
|
|
184
|
+
function storedMixed(db) { const tx = db.transaction(() => write()); tx.immediate(); tx(); }
|
|
185
|
+
function immediate(db) { db.transaction(() => write()).immediate(); }
|
|
186
|
+
function chained(db) { return db\n.transaction(() => write())\n.exclusive(); }
|
|
187
|
+
function storedImmediate(db) { const tx = db.transaction(() => write()); return tx.immediate(); }
|
|
188
|
+
`;
|
|
189
|
+
const { deferredSites, lockingSites } = scanTransactionModes([{ path: "x.ts", text }]);
|
|
190
|
+
expect([...deferredSites.keys()].sort()).toEqual([
|
|
191
|
+
"x.ts#explicitDeferred",
|
|
192
|
+
"x.ts#plainCall",
|
|
193
|
+
"x.ts#stored",
|
|
194
|
+
"x.ts#storedMixed",
|
|
195
|
+
]);
|
|
196
|
+
expect(lockingSites).toBe(3);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds a SQLite write lock from a separate process, for lock-contention tests.
|
|
3
|
+
*
|
|
4
|
+
* Run directly as `bun sqlite-write-locker-test-support.ts <db> <holdMs>`, the file
|
|
5
|
+
* opens the database, takes the write lock with BEGIN IMMEDIATE, prints `LOCKED`,
|
|
6
|
+
* holds the lock for `holdMs`, commits and prints `RELEASED`. Tests start it
|
|
7
|
+
* through `startSqliteWriteLocker`.
|
|
8
|
+
*
|
|
9
|
+
* The lock has to come from a different process. A second connection in the
|
|
10
|
+
* test's own process cannot release its lock while the test's main thread is
|
|
11
|
+
* blocked inside SQLite's busy wait, so the wait would always time out and a test
|
|
12
|
+
* could never observe a writer that waits and then succeeds.
|
|
13
|
+
*/
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
|
|
16
|
+
export interface SqliteWriteLocker {
|
|
17
|
+
/** Resolves with the locker's exit code after it has committed and exited. */
|
|
18
|
+
exited: Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Start the locker and resolve once it reports that it holds the write lock. */
|
|
22
|
+
export async function startSqliteWriteLocker(
|
|
23
|
+
dbPath: string,
|
|
24
|
+
holdMs: number,
|
|
25
|
+
): Promise<SqliteWriteLocker> {
|
|
26
|
+
const child = Bun.spawn(["bun", import.meta.path, dbPath, String(holdMs)], {
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
const reader = child.stdout.getReader();
|
|
31
|
+
const decoder = new TextDecoder();
|
|
32
|
+
let seen = "";
|
|
33
|
+
while (!seen.includes("LOCKED\n")) {
|
|
34
|
+
const chunk = await reader.read();
|
|
35
|
+
if (chunk.done) {
|
|
36
|
+
const stderr = await new Response(child.stderr).text();
|
|
37
|
+
throw new Error(`sqlite write locker exited before locking: ${stderr}`);
|
|
38
|
+
}
|
|
39
|
+
seen += decoder.decode(chunk.value, { stream: true });
|
|
40
|
+
}
|
|
41
|
+
reader.releaseLock();
|
|
42
|
+
return { exited: child.exited };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function main(): void {
|
|
46
|
+
const [dbPath, holdArg] = process.argv.slice(2);
|
|
47
|
+
const holdMs = Number(holdArg);
|
|
48
|
+
if (!dbPath || !Number.isFinite(holdMs) || holdMs < 0) {
|
|
49
|
+
process.stderr.write("usage: sqlite-write-locker-test-support.ts <db> <holdMs>\n");
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
const db = new Database(dbPath);
|
|
53
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
54
|
+
db.exec("BEGIN IMMEDIATE");
|
|
55
|
+
process.stdout.write("LOCKED\n");
|
|
56
|
+
Bun.sleepSync(holdMs);
|
|
57
|
+
db.exec("COMMIT");
|
|
58
|
+
db.close();
|
|
59
|
+
process.stdout.write("RELEASED\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (import.meta.main) main();
|