@gajae-code/coding-agent 0.18.10 → 0.18.12
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 +295 -0
- package/dist/types/async/job-manager.d.ts +7 -3
- package/dist/types/async/result-formatting.d.ts +2 -0
- package/dist/types/autoresearch/git.d.ts +1 -1
- package/dist/types/capability/fs.d.ts +2 -2
- package/dist/types/cli/accounts-cli.d.ts +2 -2
- package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
- package/dist/types/cli/install-activation.d.ts +10 -4
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/update-cli.d.ts +14 -1
- package/dist/types/cli-ordinary.d.ts +2 -2
- package/dist/types/commands/accounts.d.ts +2 -1
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +5 -0
- package/dist/types/commit/changelog/index.d.ts +1 -0
- package/dist/types/config/autorouting-tier-map.d.ts +30 -0
- package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
- package/dist/types/config/config-hot-reload.d.ts +39 -0
- package/dist/types/config/file-lock.d.ts +8 -1
- package/dist/types/config/model-discovery-manager.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -6
- package/dist/types/config/model-registry.d.ts +56 -5
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/settings-schema.d.ts +46 -3
- package/dist/types/config/settings.d.ts +33 -1
- package/dist/types/dap/config.d.ts +8 -4
- package/dist/types/debug/system-info.d.ts +6 -1
- package/dist/types/discovery/mcp-compat.d.ts +3 -1
- package/dist/types/edit/modes/patch.d.ts +5 -0
- package/dist/types/edit/modes/replace.d.ts +3 -0
- package/dist/types/exec/bash-executor.d.ts +12 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
- package/dist/types/migrate/adapters/codex.d.ts +2 -1
- package/dist/types/migrate/adapters/index.d.ts +10 -1
- package/dist/types/migrate/executor.d.ts +3 -1
- package/dist/types/migrate/types.d.ts +15 -3
- package/dist/types/modes/components/settings-selector.d.ts +6 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
- package/dist/types/runtime-mcp/manager.d.ts +7 -0
- package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
- package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
- package/dist/types/sdk/broker/broker.d.ts +7 -1
- package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
- package/dist/types/sdk/broker/ensure.d.ts +136 -7
- package/dist/types/sdk/broker/hop.d.ts +36 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
- package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
- package/dist/types/sdk/broker/session-index.d.ts +4 -1
- package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +23 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
- package/dist/types/sdk/cli/session-cli.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.d.ts +32 -0
- package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
- package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
- package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +58 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
- package/dist/types/session/messages.d.ts +5 -1
- package/dist/types/session/session-manager.d.ts +13 -3
- package/dist/types/session/startup-auth-config.d.ts +24 -2
- package/dist/types/session/streaming-output.d.ts +11 -0
- package/dist/types/session/terminal-abort.d.ts +3 -1
- package/dist/types/session-import/service.d.ts +1 -1
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
- package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
- package/dist/types/task/executor.d.ts +19 -2
- package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
- package/dist/types/tools/atomic-file-write.d.ts +9 -7
- package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
- package/dist/types/tools/descriptors.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/python.d.ts +1 -1
- package/dist/types/tools/report-issue.d.ts +41 -0
- package/dist/types/tools/subagent.d.ts +23 -0
- package/dist/types/web/scrapers/types.d.ts +8 -1
- package/dist/types/web/search/providers/insane.d.ts +7 -1
- package/dist/types/web/search/providers/searxng.d.ts +5 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +2 -1
- package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
- package/scripts/generate-docs-index.ts +8 -1
- package/scripts/generate-sdk-operation-inventory.ts +22 -4
- package/src/async/job-manager.ts +356 -56
- package/src/async/result-formatting.ts +40 -0
- package/src/autoresearch/git.ts +9 -2
- package/src/autoresearch/prompts/prompt-setup.md +1 -1
- package/src/capability/fs.ts +27 -19
- package/src/capability/index.ts +2 -2
- package/src/cli/accounts-cli.ts +176 -8
- package/src/cli/auth-broker-cli.ts +21 -11
- package/src/cli/doctor/install-repairs.ts +35 -5
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/install-activation.ts +47 -4
- package/src/cli/migrate-cli.ts +7 -5
- package/src/cli/public-command-errors.ts +4 -0
- package/src/cli/root-flags.ts +2 -2
- package/src/cli/stats-cli.ts +9 -5
- package/src/cli/update-cli.ts +224 -7
- package/src/cli-ordinary.ts +7 -2
- package/src/commands/accounts.ts +10 -5
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +199 -42
- package/src/commands/ultragoal.ts +16 -8
- package/src/commit/changelog/index.ts +39 -1
- package/src/commit/message.ts +10 -3
- package/src/config/autorouting-tier-map.ts +30 -0
- package/src/config/config-hot-reload-trust.ts +148 -0
- package/src/config/config-hot-reload.ts +734 -0
- package/src/config/file-lock.ts +216 -56
- package/src/config/model-bindings-applier.ts +85 -47
- package/src/config/model-discovery-manager.ts +10 -0
- package/src/config/model-profile-activation.ts +716 -113
- package/src/config/model-registry.ts +1334 -158
- package/src/config/model-resolver.ts +3 -1
- package/src/config/models-config-schema.ts +6 -0
- package/src/config/settings-schema.ts +60 -3
- package/src/config/settings.ts +458 -24
- package/src/coordinator-mcp/policy.ts +26 -1
- package/src/crash/sanitize.ts +4 -0
- package/src/customization/import.ts +3 -1
- package/src/dap/config.ts +31 -11
- package/src/dap/defaults.json +1 -1
- package/src/debug/crash-diagnostics.ts +2 -1
- package/src/debug/report-bundle.ts +19 -0
- package/src/debug/system-info.ts +8 -3
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
- package/src/discovery/agents-md.ts +1 -1
- package/src/discovery/helpers.ts +1 -2
- package/src/discovery/mcp-compat.ts +120 -17
- package/src/edit/apply-patch/index.ts +12 -1
- package/src/edit/index.ts +13 -17
- package/src/edit/modes/patch.ts +187 -8
- package/src/edit/modes/replace.ts +136 -0
- package/src/eval/js/context-manager.ts +29 -3
- package/src/eval/js/worker-entry.ts +51 -18
- package/src/eval/py/executor.ts +14 -4
- package/src/eval/py/runner.py +144 -5
- package/src/exec/bash-executor.ts +81 -4
- package/src/exec/bash-shell-guardian.ts +4 -1
- package/src/exec/bash-shell-supervisor.ts +8 -4
- package/src/exec/isolated-shell.ts +5 -1
- package/src/export/html/index.ts +46 -6
- package/src/extensibility/custom-tools/wrapper.ts +3 -1
- package/src/extensibility/extensions/loader.ts +8 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
- package/src/gjc-runtime/gc-runtime.ts +33 -6
- package/src/gjc-runtime/launch-tmux.ts +7 -2
- package/src/gjc-runtime/launch-worktree.test.ts +148 -0
- package/src/gjc-runtime/launch-worktree.ts +101 -14
- package/src/gjc-runtime/managed-owner-admission.ts +46 -2
- package/src/gjc-runtime/session-resolution.ts +38 -6
- package/src/gjc-runtime/session-state-lock.ts +59 -14
- package/src/gjc-runtime/state-runtime.ts +23 -19
- package/src/hooks/native-skill-hook.ts +14 -11
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/local-protocol.ts +142 -8
- package/src/lsp/index.ts +29 -0
- package/src/lsp/workspace-path.ts +23 -0
- package/src/migrate/action-planner.ts +57 -0
- package/src/migrate/adapters/claude-code.ts +23 -4
- package/src/migrate/adapters/codex.ts +22 -4
- package/src/migrate/adapters/index.ts +22 -4
- package/src/migrate/adapters/opencode.ts +6 -1
- package/src/migrate/executor.ts +84 -7
- package/src/migrate/report.ts +19 -2
- package/src/migrate/types.ts +14 -4
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/permission-mode.ts +18 -1
- package/src/modes/components/assistant-message.ts +55 -1
- package/src/modes/components/settings-selector.ts +103 -37
- package/src/modes/controllers/command-controller.ts +38 -2
- package/src/modes/controllers/plan-mode-controller.ts +17 -7
- package/src/modes/controllers/selector-controller.ts +62 -6
- package/src/modes/interactive-mode.ts +68 -0
- package/src/modes/theme/theme.ts +15 -0
- package/src/prompts/tools/debug.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/report-issue.md +13 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-credential-selector.ts +2 -2
- package/src/runtime-mcp/content-limits.ts +7 -0
- package/src/runtime-mcp/manager-pool.test.ts +5 -4
- package/src/runtime-mcp/manager.ts +32 -0
- package/src/runtime-mcp/oauth-flow.ts +32 -6
- package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
- package/src/runtime-mcp/render.ts +16 -1
- package/src/runtime-mcp/tool-bridge.ts +126 -17
- package/src/sdk/broker/broker-exit.ts +8 -5
- package/src/sdk/broker/broker.ts +342 -82
- package/src/sdk/broker/daemon-entry.ts +338 -116
- package/src/sdk/broker/doctor-restart.ts +2 -1
- package/src/sdk/broker/ensure.ts +1143 -265
- package/src/sdk/broker/hop.ts +274 -0
- package/src/sdk/broker/lifecycle-ledger.ts +35 -11
- package/src/sdk/broker/lock-host-identity.ts +29 -0
- package/src/sdk/broker/session-index.ts +62 -12
- package/src/sdk/bus/chat-command-policy.ts +16 -3
- package/src/sdk/bus/index.ts +52 -21
- package/src/sdk/bus/reconciliation-store.ts +201 -8
- package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
- package/src/sdk/cli/session-cli.ts +21 -6
- package/src/sdk/host/session-runtime.test.ts +118 -23
- package/src/sdk/host/session-runtime.ts +352 -47
- package/src/sdk/lifecycle/service.ts +1 -0
- package/src/sdk/prompt-deadline-flush.ts +78 -5
- package/src/sdk/prompt-deadline-manager.ts +68 -14
- package/src/sdk/protocol/adapter-validation.ts +53 -11
- package/src/sdk/protocol/operation-inventory.generated.json +101 -2
- package/src/sdk/reconciliation-extensions.ts +1 -0
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +114 -61
- package/src/session/agent-session.ts +1043 -81
- package/src/session/internal/managed-session-scope.ts +126 -39
- package/src/session/internal/managed-session-storage.ts +14 -11
- package/src/session/internal/native-publish-outcome.ts +5 -1
- package/src/session/messages.ts +47 -6
- package/src/session/session-manager.ts +236 -14
- package/src/session/startup-auth-config.ts +116 -8
- package/src/session/streaming-output.ts +262 -4
- package/src/session/terminal-abort.ts +14 -2
- package/src/session-import/service.ts +1 -0
- package/src/skill-state/workflow-mutation-guard.ts +491 -32
- package/src/slash-commands/builtin-registry.ts +15 -3
- package/src/slash-commands/helpers/credential-switch.ts +10 -5
- package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
- package/src/task/executor.ts +103 -12
- package/src/task/index.ts +389 -175
- package/src/tools/ast-edit-write-path.ts +64 -0
- package/src/tools/ast-edit.ts +18 -1
- package/src/tools/atomic-file-write.ts +46 -15
- package/src/tools/auto-generated-guard.ts +3 -0
- package/src/tools/bash-allowed-prefixes.ts +153 -5
- package/src/tools/bash-interactive.ts +14 -2
- package/src/tools/bash.ts +14 -0
- package/src/tools/debug.ts +7 -6
- package/src/tools/descriptors.test.ts +44 -0
- package/src/tools/descriptors.ts +34 -2
- package/src/tools/fetch.ts +3 -2
- package/src/tools/index.ts +2 -0
- package/src/tools/output-meta.ts +23 -2
- package/src/tools/python.ts +1 -1
- package/src/tools/read.ts +17 -35
- package/src/tools/report-issue.ts +184 -0
- package/src/tools/subagent-render.ts +114 -12
- package/src/tools/subagent.ts +108 -1
- package/src/tools/todo-write.ts +41 -3
- package/src/tools/tool-catalog.generated.ts +31 -3
- package/src/tools/write.ts +35 -3
- package/src/utils/commit-message-generator.ts +9 -2
- package/src/utils/file-mentions.ts +29 -1
- package/src/utils/title-generator.ts +7 -2
- package/src/web/scrapers/index.ts +3 -1
- package/src/web/scrapers/types.ts +8 -0
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/providers/insane.ts +91 -28
- package/src/web/search/providers/searxng.ts +62 -10
package/src/config/file-lock.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { nativeProcessBindings } from "@gajae-code/utils/native-process";
|
|
|
24
24
|
|
|
25
25
|
export interface FileLockOptions {
|
|
26
26
|
staleMs?: number;
|
|
27
|
+
/** Age after which an ownerless removal transition may be adopted; defaults to `staleMs`. */
|
|
28
|
+
orphanTransitionAgeMs?: number;
|
|
27
29
|
retries?: number;
|
|
28
30
|
retryDelayMs?: number;
|
|
29
31
|
signal?: AbortSignal;
|
|
@@ -91,7 +93,10 @@ export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcqui
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
const DEFAULT_OPTIONS: Required<
|
|
94
|
-
Omit<
|
|
96
|
+
Omit<
|
|
97
|
+
FileLockOptions,
|
|
98
|
+
"orphanTransitionAgeMs" | "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended"
|
|
99
|
+
>
|
|
95
100
|
> = {
|
|
96
101
|
staleMs: 10_000,
|
|
97
102
|
retries: 50,
|
|
@@ -104,11 +109,19 @@ const DEFAULT_OPTIONS: Required<
|
|
|
104
109
|
* is explicitly pre-mutation in this case, so retrying the same source name cannot
|
|
105
110
|
* publish twice or clean up a committed namespace change.
|
|
106
111
|
*/
|
|
107
|
-
const
|
|
108
|
-
const
|
|
112
|
+
const PUBLICATION_CONTENTION_RETRY_ATTEMPTS = 3;
|
|
113
|
+
const PUBLICATION_CONTENTION_RETRY_DELAY_MS = 10;
|
|
109
114
|
|
|
110
|
-
/**
|
|
115
|
+
/** Grace period after which an unpublished removal transition becomes reclaimable as orphan. */
|
|
111
116
|
const REMOVAL_TRANSITION_GRACE_MS = 10_000;
|
|
117
|
+
/**
|
|
118
|
+
* Additional buffer beyond the grace period to ensure a retained removal transition
|
|
119
|
+
* whose owner died is reclaimed promptly, decoupled from acquisition retry budget.
|
|
120
|
+
* This bounds how long a dead owner's transition can block lock acquisition.
|
|
121
|
+
*/
|
|
122
|
+
const REMOVAL_TRANSITION_ADOPTION_BUFFER_MS = 5_000;
|
|
123
|
+
export const FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS =
|
|
124
|
+
REMOVAL_TRANSITION_GRACE_MS + REMOVAL_TRANSITION_ADOPTION_BUFFER_MS;
|
|
112
125
|
|
|
113
126
|
/** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
|
|
114
127
|
export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
|
|
@@ -137,6 +150,10 @@ export const FileLockTestHooks: {
|
|
|
137
150
|
renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
|
|
138
151
|
renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
|
|
139
152
|
};
|
|
153
|
+
stagingIdentityEvidence?: () => {
|
|
154
|
+
processIncarnation: string | null;
|
|
155
|
+
startTime: string | null;
|
|
156
|
+
};
|
|
140
157
|
nativeQuarantineBindings?: () => NativeFileLockBindings;
|
|
141
158
|
nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
|
|
142
159
|
} = {};
|
|
@@ -806,8 +823,18 @@ async function exactRemoveLockTree(
|
|
|
806
823
|
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
807
824
|
const recordPath = `${transitionPath}.owner`;
|
|
808
825
|
const prior = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
809
|
-
if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, []))
|
|
826
|
+
if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
|
|
827
|
+
// A foreign transition remains untouchable. Once its tree is gone, only
|
|
828
|
+
// the remover's final record clear is pending: wait without probing its
|
|
829
|
+
// PID or deleting its record, even for an unqualified successor.
|
|
830
|
+
try {
|
|
831
|
+
await fs.lstat(transitionPath);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
if (!isEnoent(error)) throw error;
|
|
834
|
+
return { ok: false, code: "removal_record_busy" };
|
|
835
|
+
}
|
|
810
836
|
return { ok: false, code: "identity_mismatch" };
|
|
837
|
+
}
|
|
811
838
|
if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
|
|
812
839
|
try {
|
|
813
840
|
await fs.lstat(transitionPath);
|
|
@@ -818,8 +845,12 @@ async function exactRemoveLockTree(
|
|
|
818
845
|
if (!(await removeRemovalRecord(recordPath, JSON.stringify(prior))))
|
|
819
846
|
return { ok: false, code: "identity_mismatch" };
|
|
820
847
|
}
|
|
848
|
+
// A live remover still owns the sibling record, usually the previous lock
|
|
849
|
+
// generation whose tree is gone but whose record clear is still in flight.
|
|
850
|
+
// That is contention, not a changed owner: identity_mismatch makes a release
|
|
851
|
+
// final and strands this live owner's lock for every other process.
|
|
821
852
|
if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
|
|
822
|
-
return { ok: false, code: "
|
|
853
|
+
return { ok: false, code: "removal_record_busy" };
|
|
823
854
|
try {
|
|
824
855
|
return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
|
|
825
856
|
} finally {
|
|
@@ -1192,6 +1223,7 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
|
|
|
1192
1223
|
"identity_violation",
|
|
1193
1224
|
"durability_not_provable",
|
|
1194
1225
|
"sharing_violation",
|
|
1226
|
+
"access_denied",
|
|
1195
1227
|
"unknown",
|
|
1196
1228
|
] as const
|
|
1197
1229
|
).includes(value.reason as never) ||
|
|
@@ -1302,19 +1334,19 @@ function isPreMutationUnsupportedRenameResult(value: unknown): value is NativeNo
|
|
|
1302
1334
|
return true;
|
|
1303
1335
|
}
|
|
1304
1336
|
|
|
1305
|
-
function
|
|
1337
|
+
function isPreMutationPublicationContention(value: unknown): boolean {
|
|
1306
1338
|
return (
|
|
1307
1339
|
isValidNativeNoReplaceResult(value) &&
|
|
1308
1340
|
!value.ok &&
|
|
1309
1341
|
value.code === "sharing_violation" &&
|
|
1310
1342
|
value.mutationState === "not_committed" &&
|
|
1311
1343
|
value.durabilityState === "not_attempted" &&
|
|
1312
|
-
value.reason ===
|
|
1344
|
+
value.reason === value.code &&
|
|
1313
1345
|
value.phase === "rename"
|
|
1314
1346
|
);
|
|
1315
1347
|
}
|
|
1316
1348
|
|
|
1317
|
-
async function
|
|
1349
|
+
async function publishNoReplaceWithContentionRetry(
|
|
1318
1350
|
publish: (sourcePath: string, destinationPath: string) => Promise<NativeNoReplaceResult>,
|
|
1319
1351
|
sourcePath: string,
|
|
1320
1352
|
destinationPath: string,
|
|
@@ -1322,10 +1354,10 @@ async function publishNoReplaceWithSharingRetry(
|
|
|
1322
1354
|
let result = await publish(sourcePath, destinationPath);
|
|
1323
1355
|
for (
|
|
1324
1356
|
let attempt = 1;
|
|
1325
|
-
attempt <
|
|
1357
|
+
attempt < PUBLICATION_CONTENTION_RETRY_ATTEMPTS && isPreMutationPublicationContention(result);
|
|
1326
1358
|
attempt++
|
|
1327
1359
|
) {
|
|
1328
|
-
await Bun.sleep(
|
|
1360
|
+
await Bun.sleep(PUBLICATION_CONTENTION_RETRY_DELAY_MS * attempt);
|
|
1329
1361
|
result = await publish(sourcePath, destinationPath);
|
|
1330
1362
|
}
|
|
1331
1363
|
return result;
|
|
@@ -2003,7 +2035,10 @@ async function tryAcquireLock(
|
|
|
2003
2035
|
await ensureLockParent(path.dirname(lockPath));
|
|
2004
2036
|
const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
|
|
2005
2037
|
if (afterParentMkdir) await afterParentMkdir(lockPath);
|
|
2006
|
-
const
|
|
2038
|
+
const stagingIdentity = currentFileLockStagingIdentity();
|
|
2039
|
+
const pendingPath = stagingIdentity
|
|
2040
|
+
? `${lockPath}.pending.${process.pid}.${stagingIdentity}.${crypto.randomBytes(12).toString("base64url")}`
|
|
2041
|
+
: `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
|
|
2007
2042
|
const owner = lockInfo(ownerHostId, ownerToken);
|
|
2008
2043
|
let removePending = false;
|
|
2009
2044
|
try {
|
|
@@ -2083,7 +2118,7 @@ async function tryAcquireLock(
|
|
|
2083
2118
|
renameNoReplacePathAsync,
|
|
2084
2119
|
renameDirectoryNoReplacePathAsync,
|
|
2085
2120
|
};
|
|
2086
|
-
const published = await
|
|
2121
|
+
const published = await publishNoReplaceWithContentionRetry(
|
|
2087
2122
|
publication.renameNoReplacePathAsync,
|
|
2088
2123
|
canonicalPendingPath,
|
|
2089
2124
|
destinationPath,
|
|
@@ -2092,7 +2127,7 @@ async function tryAcquireLock(
|
|
|
2092
2127
|
if (published.ok && !publishedSuccessfully)
|
|
2093
2128
|
throw new Error("Failed to publish file lock: invalid primary success receipt.");
|
|
2094
2129
|
if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
|
|
2095
|
-
const fallback = await
|
|
2130
|
+
const fallback = await publishNoReplaceWithContentionRetry(
|
|
2096
2131
|
publication.renameDirectoryNoReplacePathAsync,
|
|
2097
2132
|
canonicalPendingPath,
|
|
2098
2133
|
destinationPath,
|
|
@@ -2117,6 +2152,8 @@ async function tryAcquireLock(
|
|
|
2117
2152
|
}
|
|
2118
2153
|
if (fallbackSuccessfully || verifiedCommittedPublication) {
|
|
2119
2154
|
publishedSuccessfully = true;
|
|
2155
|
+
} else if (isPreMutationPublicationContention(fallback)) {
|
|
2156
|
+
return null;
|
|
2120
2157
|
} else if (fallback.reason === "destination_exists") {
|
|
2121
2158
|
return null;
|
|
2122
2159
|
} else {
|
|
@@ -2128,7 +2165,7 @@ async function tryAcquireLock(
|
|
|
2128
2165
|
}
|
|
2129
2166
|
}
|
|
2130
2167
|
if (!publishedSuccessfully) {
|
|
2131
|
-
if (published.reason === "destination_exists") {
|
|
2168
|
+
if (isPreMutationPublicationContention(published) || published.reason === "destination_exists") {
|
|
2132
2169
|
return null;
|
|
2133
2170
|
}
|
|
2134
2171
|
const failure = new Error(
|
|
@@ -2308,14 +2345,23 @@ async function quarantineReleasedLock(
|
|
|
2308
2345
|
return false;
|
|
2309
2346
|
}
|
|
2310
2347
|
|
|
2311
|
-
async function releaseOwnedLock(
|
|
2348
|
+
async function releaseOwnedLock(
|
|
2349
|
+
lockPath: string,
|
|
2350
|
+
owner: FileLockOwnerToken,
|
|
2351
|
+
captureInitialIdentity = false,
|
|
2352
|
+
): Promise<void> {
|
|
2312
2353
|
if (await finishDetachedLockCleanup(owner)) return;
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2354
|
+
// Only the initial release may establish removal authority. Pending retries
|
|
2355
|
+
// must retain that evidence, even if a replacement carries identical info.
|
|
2356
|
+
// If the initial capture failed, a later pathname cannot supply authority.
|
|
2357
|
+
let preVerdictIdentity = fileLockDirIdentities.get(owner);
|
|
2358
|
+
if (captureInitialIdentity && !preVerdictIdentity) {
|
|
2359
|
+
try {
|
|
2360
|
+
preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
|
|
2361
|
+
if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
2364
|
+
}
|
|
2319
2365
|
}
|
|
2320
2366
|
let lastTransientError: unknown;
|
|
2321
2367
|
for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
|
|
@@ -2392,18 +2438,41 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
|
|
|
2392
2438
|
return;
|
|
2393
2439
|
}
|
|
2394
2440
|
state.status = "releasing";
|
|
2395
|
-
const releasePromise = releaseOwnedLock(lockPath, owner);
|
|
2441
|
+
const releasePromise = releaseOwnedLock(lockPath, owner, true);
|
|
2396
2442
|
state.releasePromise = releasePromise;
|
|
2397
2443
|
try {
|
|
2398
2444
|
await releasePromise;
|
|
2399
2445
|
if (localLockStates.get(key) === state) localLockStates.delete(key);
|
|
2400
2446
|
} catch (error) {
|
|
2401
2447
|
state.status = "release_pending";
|
|
2448
|
+
scheduleReleasePendingRetry(lockPath, key, state);
|
|
2402
2449
|
throw error;
|
|
2403
2450
|
} finally {
|
|
2404
2451
|
if (state.releasePromise === releasePromise) state.releasePromise = undefined;
|
|
2405
2452
|
}
|
|
2406
2453
|
}
|
|
2454
|
+
|
|
2455
|
+
const RELEASE_PENDING_RETRY_DELAYS_MS = [250, 1_000, 5_000, 15_000, 60_000];
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* A failed release otherwise waits for this process's next acquisition of the
|
|
2459
|
+
* same path. An idle process may never acquire again and other processes never
|
|
2460
|
+
* displace a live owner, so retry in the background. Each retry re-runs the full
|
|
2461
|
+
* guarded release and can only remove this owner generation.
|
|
2462
|
+
*/
|
|
2463
|
+
function scheduleReleasePendingRetry(lockPath: string, key: string, state: LocalLockState, attempt = 0): void {
|
|
2464
|
+
const delay = RELEASE_PENDING_RETRY_DELAYS_MS[attempt];
|
|
2465
|
+
if (delay === undefined) {
|
|
2466
|
+
logger.warn("File lock release is still pending after background retries", { lockPath });
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
const timer = setTimeout(async () => {
|
|
2470
|
+
if (localLockStates.get(key) !== state) return;
|
|
2471
|
+
await retryPendingLocalRelease(lockPath, key).catch(() => undefined);
|
|
2472
|
+
if (localLockStates.get(key) === state) scheduleReleasePendingRetry(lockPath, key, state, attempt + 1);
|
|
2473
|
+
}, delay);
|
|
2474
|
+
timer.unref?.();
|
|
2475
|
+
}
|
|
2407
2476
|
/**
|
|
2408
2477
|
* Bounded, actionable description of who holds `lockPath` at exhaustion time.
|
|
2409
2478
|
* Never a stealing authority: purely diagnostic, read once after the last retry.
|
|
@@ -2482,6 +2551,16 @@ async function lockHolderDescription(
|
|
|
2482
2551
|
} catch (error) {
|
|
2483
2552
|
if (!isEnoent(error)) throw error;
|
|
2484
2553
|
}
|
|
2554
|
+
try {
|
|
2555
|
+
if ((await fs.readdir(lockPath)).length === 0) {
|
|
2556
|
+
return (
|
|
2557
|
+
"held by an ownerless empty lock directory; inspect and remove this exact directory manually only after " +
|
|
2558
|
+
`confirming no publisher remains: ${manualLockCleanupCommand(lockPath)}`
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
} catch (error) {
|
|
2562
|
+
if (!isEnoent(error)) throw error;
|
|
2563
|
+
}
|
|
2485
2564
|
return "held by an unrecognized owner record";
|
|
2486
2565
|
} catch (error) {
|
|
2487
2566
|
return `held by an unreadable owner (${(error as Error).message})`;
|
|
@@ -2497,7 +2576,9 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2497
2576
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
2498
2577
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
2499
2578
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
2500
|
-
const orphanTransitionAgeMs =
|
|
2579
|
+
const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
|
|
2580
|
+
if (!Number.isFinite(orphanTransitionAgeMs) || orphanTransitionAgeMs < 0)
|
|
2581
|
+
throw new RangeError("orphanTransitionAgeMs must be a finite, non-negative number");
|
|
2501
2582
|
|
|
2502
2583
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
2503
2584
|
const lockPath = getLockPath(filePath);
|
|
@@ -2527,16 +2608,20 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2527
2608
|
ownerToken,
|
|
2528
2609
|
opts.onAcquired,
|
|
2529
2610
|
);
|
|
2530
|
-
if (isFileLockOrphanTransition(result))
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2611
|
+
if (isFileLockOrphanTransition(result)) {
|
|
2612
|
+
// Adoption is identity-bound and can be refused by a transient cleanup race.
|
|
2613
|
+
// Keep using the caller's bounded contention budget; the same generation is
|
|
2614
|
+
// revalidated before every later adoption attempt.
|
|
2615
|
+
if (attempt + 1 === opts.retries)
|
|
2616
|
+
throw new FileLockAcquireError(
|
|
2617
|
+
filePath,
|
|
2618
|
+
lockPath,
|
|
2619
|
+
opts.retries,
|
|
2620
|
+
`orphan transition retained at ${result.path}`,
|
|
2621
|
+
"orphan_transition",
|
|
2622
|
+
result.path,
|
|
2623
|
+
);
|
|
2624
|
+
} else if (result) {
|
|
2540
2625
|
localLockStates.set(localKey, { owner: result, status: "held" });
|
|
2541
2626
|
return () => releaseLock(lockPath, result, localKey);
|
|
2542
2627
|
}
|
|
@@ -2585,20 +2670,26 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2585
2670
|
opts.signal.removeEventListener("abort", onAbort);
|
|
2586
2671
|
}
|
|
2587
2672
|
}
|
|
2673
|
+
const holder = await lockHolderDescription(
|
|
2674
|
+
lockPath,
|
|
2675
|
+
orphanTransitionAgeMs,
|
|
2676
|
+
opts.ownerHostId,
|
|
2677
|
+
opts.previousOwnerHostIds ?? [],
|
|
2678
|
+
);
|
|
2679
|
+
const transitionState = await classifyFileLockRemovalTransition(
|
|
2680
|
+
lockPath,
|
|
2681
|
+
orphanTransitionAgeMs,
|
|
2682
|
+
opts.ownerHostId,
|
|
2683
|
+
opts.previousOwnerHostIds ?? [],
|
|
2684
|
+
);
|
|
2685
|
+
const transitionPath = transitionState ? fileLockRemovalTransitionPath(lockPath) : undefined;
|
|
2588
2686
|
throw new FileLockAcquireError(
|
|
2589
2687
|
filePath,
|
|
2590
2688
|
lockPath,
|
|
2591
2689
|
opts.retries,
|
|
2592
|
-
|
|
2593
|
-
"acquire_timeout",
|
|
2594
|
-
|
|
2595
|
-
lockPath,
|
|
2596
|
-
orphanTransitionAgeMs,
|
|
2597
|
-
opts.ownerHostId,
|
|
2598
|
-
opts.previousOwnerHostIds ?? [],
|
|
2599
|
-
))
|
|
2600
|
-
? fileLockRemovalTransitionPath(lockPath)
|
|
2601
|
-
: undefined,
|
|
2690
|
+
holder,
|
|
2691
|
+
transitionState === "orphan_transition" ? "orphan_transition" : "acquire_timeout",
|
|
2692
|
+
transitionPath,
|
|
2602
2693
|
await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
|
|
2603
2694
|
);
|
|
2604
2695
|
}
|
|
@@ -2630,14 +2721,54 @@ export async function withFileLock<T>(
|
|
|
2630
2721
|
return result;
|
|
2631
2722
|
}
|
|
2632
2723
|
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2724
|
+
type FileLockStagingIdentity = { kind: "process_incarnation" | "start_time"; value: string };
|
|
2725
|
+
type FileLockStagingOwner = { pid: number; identity?: FileLockStagingIdentity };
|
|
2726
|
+
|
|
2727
|
+
const FILE_LOCK_STAGING_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
2728
|
+
const FILE_LOCK_STAGING_NAME_WITH_IDENTITY =
|
|
2729
|
+
/^.+\.lock\.pending\.([1-9]\d*)\.([is][A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{16})$/;
|
|
2730
|
+
const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(`^.+\\.lock\\.pending\\.([1-9]\\d*)\\.${FILE_LOCK_STAGING_UUID}$`);
|
|
2731
|
+
|
|
2732
|
+
function fileLockStagingOwner(name: string): FileLockStagingOwner | null {
|
|
2733
|
+
const current = FILE_LOCK_STAGING_NAME_WITH_IDENTITY.exec(name);
|
|
2734
|
+
const legacy = current ? null : LEGACY_FILE_LOCK_STAGING_NAME.exec(name);
|
|
2735
|
+
const match = current ?? legacy;
|
|
2638
2736
|
if (!match) return null;
|
|
2639
2737
|
const pid = Number(match[1]);
|
|
2640
|
-
|
|
2738
|
+
if (!Number.isSafeInteger(pid)) return null;
|
|
2739
|
+
if (!current) return { pid };
|
|
2740
|
+
const encodedIdentity = current[2] ?? "";
|
|
2741
|
+
return {
|
|
2742
|
+
pid,
|
|
2743
|
+
identity: {
|
|
2744
|
+
kind: encodedIdentity.startsWith("i") ? "process_incarnation" : "start_time",
|
|
2745
|
+
value: encodedIdentity.slice(1),
|
|
2746
|
+
},
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
function fileLockStagingIdentityHash(identity: string): string {
|
|
2751
|
+
// Ninety-six bits are sufficient to disambiguate process generations while
|
|
2752
|
+
// keeping the sibling name within the old component-length envelope.
|
|
2753
|
+
return crypto.createHash("sha256").update(identity, "utf8").digest("base64url").slice(0, 16);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/**
|
|
2757
|
+
* A missing generation is represented by a legacy UUID name that forces a
|
|
2758
|
+
* liveness probe; never hash a synthetic "unknown" as if it were identity evidence.
|
|
2759
|
+
*/
|
|
2760
|
+
function currentFileLockStagingIdentity(): string | null {
|
|
2761
|
+
const evidence = FileLockTestHooks.stagingIdentityEvidence?.();
|
|
2762
|
+
const incarnation = evidence ? evidence.processIncarnation : currentProcessIncarnation();
|
|
2763
|
+
if (incarnation) return `i${fileLockStagingIdentityHash(incarnation)}`;
|
|
2764
|
+
const startTime = evidence ? evidence.startTime : currentProcessStartTime();
|
|
2765
|
+
if (!startTime || startTime === "unknown") return null;
|
|
2766
|
+
return `s${fileLockStagingIdentityHash(startTime)}`;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
/** Return a name-encoded staging PID for GC discovery. */
|
|
2770
|
+
export function fileLockStagingOwnerPid(name: string): number | null {
|
|
2771
|
+
return fileLockStagingOwner(name)?.pid ?? null;
|
|
2641
2772
|
}
|
|
2642
2773
|
|
|
2643
2774
|
export interface FileLockStagingResult {
|
|
@@ -2660,8 +2791,9 @@ export async function inspectFileLockStagingDir(
|
|
|
2660
2791
|
removed: false,
|
|
2661
2792
|
reason: "unverified_staging_directory",
|
|
2662
2793
|
};
|
|
2663
|
-
const
|
|
2664
|
-
if (
|
|
2794
|
+
const nameOwner = fileLockStagingOwner(path.basename(stagingPath));
|
|
2795
|
+
if (!nameOwner) return kept;
|
|
2796
|
+
const namePid = nameOwner.pid;
|
|
2665
2797
|
const canonical = await canonicalLockPathPreservingFinal(stagingPath);
|
|
2666
2798
|
// A winning acquirer publishes (renames) its staging directory concurrently, so the
|
|
2667
2799
|
// candidate can disappear between the caller's readdir and this observation.
|
|
@@ -2673,6 +2805,36 @@ export async function inspectFileLockStagingDir(
|
|
|
2673
2805
|
return { ...kept, reason: "enoent_already_gone" };
|
|
2674
2806
|
}
|
|
2675
2807
|
if (!root.isDirectory() || root.isSymbolicLink()) return kept;
|
|
2808
|
+
let namePidStatus: OwnerLiveness;
|
|
2809
|
+
if (nameOwner.identity) {
|
|
2810
|
+
const currentIdentity =
|
|
2811
|
+
nameOwner.identity.kind === "process_incarnation" ? processIncarnation(namePid) : processStartTime(namePid);
|
|
2812
|
+
if (currentIdentity !== null && fileLockStagingIdentityHash(currentIdentity) === nameOwner.identity.value) {
|
|
2813
|
+
return { ...kept, pid: namePid, status: "alive", reason: "live_staging_name_owner" };
|
|
2814
|
+
}
|
|
2815
|
+
const syntheticStartTimeFallback =
|
|
2816
|
+
nameOwner.identity.kind === "start_time" &&
|
|
2817
|
+
(nameOwner.identity.value === fileLockStagingIdentityHash(`${namePid}:unknown`) ||
|
|
2818
|
+
(currentIdentity !== null &&
|
|
2819
|
+
nameOwner.identity.value === fileLockStagingIdentityHash(`${namePid}:${currentIdentity}`)));
|
|
2820
|
+
// Older publishers hashed `${pid}:${startTime}` or `${pid}:unknown` when
|
|
2821
|
+
// start-time evidence was unavailable. A later raw start-time mismatch is
|
|
2822
|
+
// not proof those already-running staging owners are dead.
|
|
2823
|
+
namePidStatus = currentIdentity === null || syntheticStartTimeFallback ? probe(namePid) : "dead";
|
|
2824
|
+
} else {
|
|
2825
|
+
namePidStatus = probe(namePid);
|
|
2826
|
+
}
|
|
2827
|
+
if (namePidStatus !== "dead") {
|
|
2828
|
+
// Do not open the publication source while its owner may be live. On Windows an
|
|
2829
|
+
// info-file handle can prevent the owner's atomic directory rename. Legacy names
|
|
2830
|
+
// without generation evidence and unknown liveness fail closed.
|
|
2831
|
+
return {
|
|
2832
|
+
...kept,
|
|
2833
|
+
pid: namePid,
|
|
2834
|
+
status: namePidStatus,
|
|
2835
|
+
reason: namePidStatus === "alive" ? "live_staging_name_owner" : "staging_name_owner_liveness_unknown",
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2676
2838
|
const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
|
|
2677
2839
|
if (
|
|
2678
2840
|
!captured.ok ||
|
|
@@ -2702,10 +2864,8 @@ export async function inspectFileLockStagingDir(
|
|
|
2702
2864
|
if (captured.snapshot.entries.some(entry => entry.relativePath !== "")) return kept;
|
|
2703
2865
|
pid = namePid;
|
|
2704
2866
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
}
|
|
2708
|
-
let status = probe(pid);
|
|
2867
|
+
let status = pid === namePid ? namePidStatus : probe(pid);
|
|
2868
|
+
if (pid === process.pid && observation && !ownerIncarnationChanged(observation.info)) status = "alive";
|
|
2709
2869
|
if (status === "alive" && observation && ownerIncarnationChanged(observation.info)) status = "dead";
|
|
2710
2870
|
const result: FileLockStagingResult = {
|
|
2711
2871
|
path: stagingPath,
|
|
@@ -10,12 +10,14 @@ export interface ConfiguredModelBindings {
|
|
|
10
10
|
export class ModelBindingsApplier {
|
|
11
11
|
#targetSettings: Settings | undefined;
|
|
12
12
|
#bindings: ConfiguredModelBindings | undefined;
|
|
13
|
+
#lastAppliedRoles = new Map<string, ModelSelectorValue>();
|
|
14
|
+
#lastAppliedAgentOverrides = new Map<string, ModelSelectorValue>();
|
|
15
|
+
#manualRoles = new Map<string, ModelSelectorValue | undefined>();
|
|
16
|
+
#manualAgentOverrides = new Map<string, ModelSelectorValue | undefined>();
|
|
13
17
|
#appliedRoles = new Set<string>();
|
|
14
18
|
#appliedAgentOverrides = new Set<string>();
|
|
15
19
|
#roleBaselines = new Map<string, ModelSelectorValue | undefined>();
|
|
16
20
|
#agentBaselines = new Map<string, ModelSelectorValue | undefined>();
|
|
17
|
-
#lastAppliedRoles = new Map<string, ModelSelectorValue>();
|
|
18
|
-
#lastAppliedAgentOverrides = new Map<string, ModelSelectorValue>();
|
|
19
21
|
|
|
20
22
|
/** The currently configured bindings (as installed at startup), for baseline lookup. */
|
|
21
23
|
getBindings(): ConfiguredModelBindings | undefined {
|
|
@@ -71,6 +73,8 @@ export class ModelBindingsApplier {
|
|
|
71
73
|
this.#targetSettings = targetSettings;
|
|
72
74
|
const bindings = this.#bindings;
|
|
73
75
|
if (!targetSettings) return;
|
|
76
|
+
for (const role of Object.keys(bindings?.modelRoles ?? {})) this.#manualRoles.delete(role);
|
|
77
|
+
for (const role of Object.keys(bindings?.agentModelOverrides ?? {})) this.#manualAgentOverrides.delete(role);
|
|
74
78
|
const modelRoles = { ...(targetSettings.get("modelRoles") ?? {}) };
|
|
75
79
|
this.#forceSync(
|
|
76
80
|
modelRoles,
|
|
@@ -98,6 +102,13 @@ export class ModelBindingsApplier {
|
|
|
98
102
|
baselines: Map<string, ModelSelectorValue | undefined>,
|
|
99
103
|
lastApplied: Map<string, ModelSelectorValue>,
|
|
100
104
|
): void {
|
|
105
|
+
const configuredKeys = new Set(Object.keys(configured));
|
|
106
|
+
for (const key of applied) {
|
|
107
|
+
if (configuredKeys.has(key)) continue;
|
|
108
|
+
const baseline = baselines.get(key);
|
|
109
|
+
if (baseline === undefined) delete target[key];
|
|
110
|
+
else target[key] = this.#clone(baseline)!;
|
|
111
|
+
}
|
|
101
112
|
for (const [key, value] of Object.entries(configured)) {
|
|
102
113
|
if (!baselines.has(key)) baselines.set(key, this.#clone(target[key]));
|
|
103
114
|
target[key] = this.#clone(value)!;
|
|
@@ -111,82 +122,109 @@ export class ModelBindingsApplier {
|
|
|
111
122
|
const targetSettings = this.#targetSettings;
|
|
112
123
|
if (!targetSettings) return;
|
|
113
124
|
const bindings = this.#bindings;
|
|
114
|
-
const nextModelRoles = { ...(targetSettings.get("modelRoles") ?? {}) };
|
|
115
125
|
this.#sync(
|
|
116
|
-
|
|
126
|
+
targetSettings,
|
|
127
|
+
"modelRoles",
|
|
117
128
|
bindings?.modelRoles ?? {},
|
|
118
|
-
this.#appliedRoles,
|
|
119
|
-
this.#roleBaselines,
|
|
120
129
|
this.#lastAppliedRoles,
|
|
130
|
+
this.#manualRoles,
|
|
131
|
+
this.#roleBaselines,
|
|
121
132
|
);
|
|
122
|
-
targetSettings.override("modelRoles", nextModelRoles);
|
|
123
|
-
|
|
124
|
-
const nextAgentOverrides = { ...(targetSettings.get("task.agentModelOverrides") ?? {}) };
|
|
125
133
|
this.#sync(
|
|
126
|
-
|
|
134
|
+
targetSettings,
|
|
135
|
+
"task.agentModelOverrides",
|
|
127
136
|
bindings?.agentModelOverrides ?? {},
|
|
128
|
-
this.#appliedAgentOverrides,
|
|
129
|
-
this.#agentBaselines,
|
|
130
137
|
this.#lastAppliedAgentOverrides,
|
|
138
|
+
this.#manualAgentOverrides,
|
|
139
|
+
this.#agentBaselines,
|
|
131
140
|
);
|
|
132
|
-
targetSettings.override("task.agentModelOverrides", nextAgentOverrides);
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
#restoreTarget(targetSettings: Settings): void {
|
|
136
|
-
const modelRoles = { ...(targetSettings.get("modelRoles") ?? {}) };
|
|
137
|
-
this.#sync(modelRoles, {}, this.#appliedRoles, this.#roleBaselines, this.#lastAppliedRoles);
|
|
138
|
-
targetSettings.override("modelRoles", modelRoles);
|
|
139
|
-
|
|
140
|
-
const agentOverrides = { ...(targetSettings.get("task.agentModelOverrides") ?? {}) };
|
|
141
144
|
this.#sync(
|
|
142
|
-
|
|
145
|
+
targetSettings,
|
|
146
|
+
"task.agentModelOverrides",
|
|
143
147
|
{},
|
|
144
|
-
this.#appliedAgentOverrides,
|
|
145
|
-
this.#agentBaselines,
|
|
146
148
|
this.#lastAppliedAgentOverrides,
|
|
149
|
+
this.#manualAgentOverrides,
|
|
150
|
+
this.#agentBaselines,
|
|
147
151
|
);
|
|
148
|
-
targetSettings
|
|
152
|
+
this.#sync(targetSettings, "modelRoles", {}, this.#lastAppliedRoles, this.#manualRoles, this.#roleBaselines);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
#clearTargetLifecycle(): void {
|
|
152
|
-
this.#appliedRoles.clear();
|
|
153
|
-
this.#appliedAgentOverrides.clear();
|
|
154
|
-
this.#roleBaselines.clear();
|
|
155
|
-
this.#agentBaselines.clear();
|
|
156
156
|
this.#lastAppliedRoles.clear();
|
|
157
157
|
this.#lastAppliedAgentOverrides.clear();
|
|
158
|
+
this.#manualRoles.clear();
|
|
159
|
+
this.#manualAgentOverrides.clear();
|
|
160
|
+
this.#roleBaselines.clear();
|
|
161
|
+
this.#agentBaselines.clear();
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
#sync(
|
|
161
|
-
|
|
165
|
+
targetSettings: Settings,
|
|
166
|
+
settingPath: "modelRoles" | "task.agentModelOverrides",
|
|
162
167
|
configured: Record<string, ModelSelectorValue>,
|
|
163
|
-
applied: Set<string>,
|
|
164
|
-
baselines: Map<string, ModelSelectorValue | undefined>,
|
|
165
168
|
lastApplied: Map<string, ModelSelectorValue>,
|
|
169
|
+
manualOverrides: Map<string, ModelSelectorValue | undefined>,
|
|
170
|
+
baselines: Map<string, ModelSelectorValue | undefined>,
|
|
166
171
|
): void {
|
|
167
172
|
const configuredKeys = new Set(Object.keys(configured));
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
const current = (targetSettings.get(settingPath) ?? {}) as Record<string, ModelSelectorValue>;
|
|
174
|
+
const global = (targetSettings.getGlobal(settingPath) ?? {}) as Record<string, ModelSelectorValue>;
|
|
175
|
+
const runtime = targetSettings.getOverride(settingPath) ?? {};
|
|
176
|
+
for (const key of configuredKeys) {
|
|
177
|
+
if (!lastApplied.has(key) && !baselines.has(key)) baselines.set(key, this.#clone(runtime[key]));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Manual bindings remain authoritative, but their cached values must follow
|
|
181
|
+
// later edits and removals from the runtime override slots.
|
|
182
|
+
for (const key of manualOverrides.keys()) {
|
|
183
|
+
manualOverrides.set(key, this.#clone(runtime[key]));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Keep existing non-global values that belong to another runtime layer.
|
|
187
|
+
for (const [key, value] of Object.entries(current)) {
|
|
188
|
+
if (configuredKeys.has(key) || lastApplied.has(key) || manualOverrides.has(key)) continue;
|
|
189
|
+
if (!this.#equal(value, global[key])) manualOverrides.set(key, this.#clone(value)!);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A value changed while our previous overlay was active is a manual edit.
|
|
193
|
+
for (const [key, previous] of lastApplied) {
|
|
194
|
+
const currentValue = current[key];
|
|
195
|
+
if (!Object.hasOwn(runtime, key) || !Object.hasOwn(current, key)) {
|
|
196
|
+
manualOverrides.set(key, undefined);
|
|
197
|
+
} else if (!this.#equal(runtime[key], previous)) {
|
|
198
|
+
manualOverrides.set(key, this.#clone(runtime[key])!);
|
|
199
|
+
} else if (!this.#equal(currentValue, previous)) {
|
|
200
|
+
manualOverrides.set(key, this.#clone(currentValue)!);
|
|
175
201
|
}
|
|
176
|
-
baselines.delete(key);
|
|
177
|
-
lastApplied.delete(key);
|
|
178
202
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
|
|
204
|
+
const nextOverrides: Record<string, ModelSelectorValue> = {};
|
|
205
|
+
for (const [key, value] of Object.entries(runtime)) {
|
|
206
|
+
if (!lastApplied.has(key) && !configuredKeys.has(key)) nextOverrides[key] = this.#clone(value)!;
|
|
207
|
+
}
|
|
208
|
+
for (const [key, baseline] of baselines) {
|
|
209
|
+
if (!configuredKeys.has(key)) {
|
|
210
|
+
if (baseline !== undefined) nextOverrides[key] = this.#clone(baseline)!;
|
|
211
|
+
baselines.delete(key);
|
|
186
212
|
}
|
|
187
213
|
}
|
|
188
|
-
|
|
189
|
-
|
|
214
|
+
for (const [key, value] of manualOverrides) {
|
|
215
|
+
if (value === undefined) delete nextOverrides[key];
|
|
216
|
+
else nextOverrides[key] = this.#clone(value)!;
|
|
217
|
+
}
|
|
218
|
+
lastApplied.clear();
|
|
219
|
+
for (const [key, value] of Object.entries(configured)) {
|
|
220
|
+
if (manualOverrides.has(key)) continue;
|
|
221
|
+
const appliedValue = this.#clone(value)!;
|
|
222
|
+
nextOverrides[key] = appliedValue;
|
|
223
|
+
lastApplied.set(key, this.#clone(appliedValue)!);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (Object.keys(nextOverrides).length === 0) targetSettings.clearOverride(settingPath);
|
|
227
|
+
else targetSettings.override(settingPath, nextOverrides);
|
|
190
228
|
}
|
|
191
229
|
|
|
192
230
|
#cloneBindings(
|