@gajae-code/coding-agent 0.11.10 → 0.11.11
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 +38 -0
- package/dist/types/cli.d.ts +8 -1
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-profile-contract.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +57 -2
- package/dist/types/config/settings.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
- package/dist/types/modes/acp/acp-agent.d.ts +14 -9
- package/dist/types/modes/components/queue-pane.d.ts +8 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +5 -1
- package/dist/types/runtime/memory-domain.d.ts +9 -0
- package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
- package/dist/types/runtime/memory-guard.d.ts +39 -0
- package/dist/types/runtime/memory-limit.d.ts +11 -0
- package/dist/types/runtime-mcp/manager.d.ts +8 -0
- package/dist/types/sdk/acp/final-text.d.ts +19 -0
- package/dist/types/sdk/acp/mcp.d.ts +23 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +3 -1
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
- package/dist/types/sdk/host/control/operations.d.ts +1 -0
- package/dist/types/sdk/lifecycle-session.d.ts +10 -1
- package/dist/types/sdk/startup-capability.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +9 -11
- package/dist/types/session/fallback-chain-controller.d.ts +18 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
- package/dist/types/session/session-manager.d.ts +34 -0
- package/dist/types/task/executor.d.ts +6 -0
- package/dist/types/task/types.d.ts +36 -0
- package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
- package/dist/types/tools/resource-gc.d.ts +19 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-test-manifest.ts +6 -4
- package/src/cli/args.ts +14 -8
- package/src/cli/fast-help.ts +1 -1
- package/src/cli.ts +48 -2
- package/src/commands/sdk.ts +91 -7
- package/src/config/model-profile-activation.ts +1 -0
- package/src/config/model-profile-contract.ts +13 -0
- package/src/config/model-profiles.ts +24 -0
- package/src/config/settings-schema.ts +61 -2
- package/src/config/settings.ts +8 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +14 -0
- package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
- package/src/gjc-runtime/deep-interview-state.ts +25 -3
- package/src/gjc-runtime/linux-proc.ts +70 -36
- package/src/gjc-runtime/ralplan-runtime.ts +252 -1
- package/src/gjc-runtime/state-runtime.ts +48 -11
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/local-protocol.ts +23 -4
- package/src/modes/acp/acp-agent.ts +554 -60
- package/src/modes/acp/acp-event-mapper.ts +93 -6
- package/src/modes/components/model-selector.ts +15 -1
- package/src/modes/components/notifications-settings-editor.ts +1 -1
- package/src/modes/components/queue-pane.ts +55 -9
- package/src/modes/components/queued-message-selector.ts +53 -10
- package/src/modes/components/tool-status-header.ts +1 -4
- package/src/modes/components/welcome.ts +18 -3
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -10
- package/src/modes/controllers/input-controller.ts +44 -3
- package/src/modes/interactive-mode.ts +65 -26
- package/src/modes/runtime-init.ts +1 -0
- package/src/prompts/agents/executor.md +1 -1
- package/src/runtime/memory-domain.ts +66 -0
- package/src/runtime/memory-guard-contract.ts +73 -0
- package/src/runtime/memory-guard.ts +292 -0
- package/src/runtime/memory-limit.ts +50 -0
- package/src/runtime-mcp/manager.ts +26 -6
- package/src/sdk/acp/adapter.ts +8 -1
- package/src/sdk/acp/final-text.ts +40 -0
- package/src/sdk/acp/mcp.ts +26 -0
- package/src/sdk/broker/lifecycle.ts +82 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -2
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/config.ts +1 -1
- package/src/sdk/bus/index.ts +162 -22
- package/src/sdk/bus/rate-limit-pool.ts +8 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
- package/src/sdk/bus/telegram-daemon.ts +531 -53
- package/src/sdk/host/control/dispatch.ts +2 -0
- package/src/sdk/host/control/operations.ts +1 -0
- package/src/sdk/host/host.ts +2 -2
- package/src/sdk/lifecycle-session.ts +19 -3
- package/src/sdk/protocol/operation-inventory.generated.json +47 -0
- package/src/sdk/protocol/operation-registry.ts +1 -0
- package/src/sdk/session.ts +12 -1
- package/src/sdk/startup-capability.ts +6 -0
- package/src/session/agent-session.ts +256 -142
- package/src/session/fallback-chain-controller.ts +47 -8
- package/src/session/internal/managed-session-scope.ts +40 -10
- package/src/session/internal/native-publish-outcome.ts +8 -3
- package/src/session/session-manager.ts +608 -25
- package/src/task/executor.ts +11 -3
- package/src/task/index.ts +2 -0
- package/src/task/types.ts +22 -11
- package/src/task/ultragoal-redteam-activation.ts +75 -0
- package/src/tools/resource-gc.ts +496 -120
- package/src/workflow/workflow-intent-diff.ts +4 -1
|
@@ -18,6 +18,7 @@ import chalk from "chalk";
|
|
|
18
18
|
import { AsyncJobManager } from "../async";
|
|
19
19
|
import {
|
|
20
20
|
type AppKeybinding,
|
|
21
|
+
defaultMessageQueueKeysForPlatform,
|
|
21
22
|
formatKeyHint,
|
|
22
23
|
formatKeyHints,
|
|
23
24
|
KeybindingsManager,
|
|
@@ -47,6 +48,7 @@ import type { SessionContext, SessionManager } from "../session/session-manager"
|
|
|
47
48
|
import { getRecentSessions, getSessionMessageEntryId } from "../session/session-manager";
|
|
48
49
|
import type { LspStartupServerInfo } from "../tools";
|
|
49
50
|
import { formatPhaseDisplayName } from "../tools/todo-write";
|
|
51
|
+
import { copyToClipboard } from "../utils/clipboard";
|
|
50
52
|
import type { EventBus } from "../utils/event-bus";
|
|
51
53
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
|
52
54
|
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
|
@@ -113,16 +115,60 @@ import {
|
|
|
113
115
|
import type { ParsedIrcMessage } from "./utils/irc-message";
|
|
114
116
|
import { addChatChild, prepareTranscriptRebuild, UiHelpers } from "./utils/ui-helpers";
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
function buildComposerPlaceholder(
|
|
119
|
+
keybindings: Pick<KeybindingsManager, "getDisplayString">,
|
|
120
|
+
context: KeyDisplayContext,
|
|
121
|
+
options: { readonly busy: boolean; readonly busyPromptMode: "steer" | "queue" },
|
|
122
|
+
): string {
|
|
123
|
+
const parts: string[] = [];
|
|
124
|
+
const submitKey = options.busy ? keybindings.getDisplayString("tui.input.submit", context) : "";
|
|
125
|
+
if (submitKey) {
|
|
126
|
+
const submitAction = options.busyPromptMode === "steer" ? "Steer" : "Queue";
|
|
127
|
+
parts.push(`${submitKey}: ${submitAction}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const queueKey = keybindings.getDisplayString("app.message.queue", context);
|
|
131
|
+
const submitQueues = options.busy && options.busyPromptMode === "queue" && submitKey;
|
|
132
|
+
if (queueKey && !submitQueues) parts.push(`${queueKey}: ${options.busy ? "Queue" : "Queue (busy)"}`);
|
|
133
|
+
|
|
134
|
+
const actionHints = [
|
|
135
|
+
["app.thinking.cycle", "Thinking"],
|
|
136
|
+
["app.model.select", "Model"],
|
|
137
|
+
["app.history.search", "History"],
|
|
138
|
+
] as const;
|
|
139
|
+
for (const [id, label] of actionHints) {
|
|
140
|
+
const key = keybindings.getDisplayString(id, context);
|
|
141
|
+
if (key) parts.push(`${key}: ${label}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
const newlineKeys = context.platform === "win32" ? ["alt+enter", "ctrl+j"] : ["shift+enter", "ctrl+j"];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
parts.push(`${formatKeyHints(newlineKeys, context)}: New line`, `${formatKeyHint("ctrl+c", context)}: Clear`);
|
|
146
|
+
return `Type your message... ${parts.join(" · ")}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getDefaultComposerPlaceholder(
|
|
150
|
+
context: KeyDisplayContext = { platform: process.platform },
|
|
151
|
+
keybindings?: Pick<KeybindingsManager, "getDisplayString">,
|
|
152
|
+
): string {
|
|
153
|
+
const effectiveKeybindings =
|
|
154
|
+
keybindings ??
|
|
155
|
+
KeybindingsManager.inMemory({
|
|
156
|
+
"app.message.queue": defaultMessageQueueKeysForPlatform(context.platform),
|
|
157
|
+
});
|
|
158
|
+
return buildComposerPlaceholder(effectiveKeybindings, context, { busy: false, busyPromptMode: "steer" });
|
|
122
159
|
}
|
|
123
160
|
|
|
124
161
|
export const DEFAULT_COMPOSER_PLACEHOLDER = getDefaultComposerPlaceholder();
|
|
162
|
+
|
|
163
|
+
export function getComposerPlaceholder(
|
|
164
|
+
keybindings: Pick<KeybindingsManager, "getDisplayString">,
|
|
165
|
+
context: KeyDisplayContext,
|
|
166
|
+
options: { readonly busy: boolean; readonly busyPromptMode: "steer" | "queue" },
|
|
167
|
+
): string {
|
|
168
|
+
return buildComposerPlaceholder(keybindings, context, options);
|
|
169
|
+
}
|
|
125
170
|
const WELCOME_RESERVED_CONTAINER_CHILD_LIMIT = 8;
|
|
171
|
+
const COMPOSER_RIGHT_GUTTER_WIDTH = 1;
|
|
126
172
|
|
|
127
173
|
const IRC_SIDEBAR_TOGGLE_SHADOWING_ACTIONS: readonly AppKeybinding[] = [
|
|
128
174
|
"app.plan.toggle",
|
|
@@ -169,7 +215,7 @@ function configureDefaultComposerChrome(editor: CustomEditor): void {
|
|
|
169
215
|
editor.setInputPrefix(getDefaultInputPrefix());
|
|
170
216
|
editor.setPlaceholder(getDefaultComposerPlaceholder());
|
|
171
217
|
editor.setPaddingX(1);
|
|
172
|
-
editor.setRightGutterWidth(
|
|
218
|
+
editor.setRightGutterWidth(COMPOSER_RIGHT_GUTTER_WIDTH);
|
|
173
219
|
editor.setTopBorder(undefined);
|
|
174
220
|
}
|
|
175
221
|
|
|
@@ -510,6 +556,14 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
510
556
|
|
|
511
557
|
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"), {
|
|
512
558
|
enableMouse: settings.get("mouse.enabled"),
|
|
559
|
+
copySelection: async text => {
|
|
560
|
+
try {
|
|
561
|
+
await copyToClipboard(text);
|
|
562
|
+
this.showStatus("Selection copied to clipboard");
|
|
563
|
+
} catch (error) {
|
|
564
|
+
this.showError(`Failed to copy selection: ${error instanceof Error ? error.message : String(error)}`);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
513
567
|
});
|
|
514
568
|
this.ui.setClearOnShrink(settings.get("clearOnShrink"));
|
|
515
569
|
this.chatContainer = new Container();
|
|
@@ -660,6 +714,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
660
714
|
getViewportRows: () => this.ui.terminal.rows,
|
|
661
715
|
getReservedBottomRows: getWelcomeReservedBottomRows,
|
|
662
716
|
changelogMarkdown: this.#changelogMarkdown,
|
|
717
|
+
rightGutterWidth: COMPOSER_RIGHT_GUTTER_WIDTH,
|
|
663
718
|
collapseChangelog: settings.get("collapseChangelog"),
|
|
664
719
|
keyDisplayContext: this.#keyDisplayContext,
|
|
665
720
|
},
|
|
@@ -1056,27 +1111,11 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1056
1111
|
return this.session.isStreaming || this.session.isCompacting;
|
|
1057
1112
|
}
|
|
1058
1113
|
|
|
1059
|
-
#getFirstKeyForAction(action: AppKeybinding): string | undefined {
|
|
1060
|
-
return this.keybindings.getKeys(action)[0];
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
#getMessageQueueShortcut(): string | undefined {
|
|
1064
|
-
const preferredAction: AppKeybinding =
|
|
1065
|
-
process.platform === "darwin" ? "app.message.followUp" : "app.message.queue";
|
|
1066
|
-
const fallbackAction: AppKeybinding =
|
|
1067
|
-
process.platform === "darwin" ? "app.message.queue" : "app.message.followUp";
|
|
1068
|
-
return this.#getFirstKeyForAction(preferredAction) ?? this.#getFirstKeyForAction(fallbackAction);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
1114
|
#getComposerPlaceholder(): string {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const parts = submitKey ? [`${submitKey}: ${submitAction}`] : [];
|
|
1077
|
-
const queueKey = this.#getMessageQueueShortcut();
|
|
1078
|
-
if (queueKey) parts.push(`${formatKeyHint(queueKey, this.#keyDisplayContext)}: Queue`);
|
|
1079
|
-
return parts.length > 0 ? `${defaultPlaceholder} · ${parts.join(" · ")}` : defaultPlaceholder;
|
|
1115
|
+
return getComposerPlaceholder(this.keybindings, this.#keyDisplayContext, {
|
|
1116
|
+
busy: this.#isPromptDeliveryBusy(),
|
|
1117
|
+
busyPromptMode: this.settings.get("busyPromptMode"),
|
|
1118
|
+
});
|
|
1080
1119
|
}
|
|
1081
1120
|
|
|
1082
1121
|
#getWelcomeReservedRows(width: number): number {
|
|
@@ -115,6 +115,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
|
|
|
115
115
|
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
|
116
116
|
clearContext: () => session.clearContext(),
|
|
117
117
|
cycleModel: () => session.cycleModel(),
|
|
118
|
+
setModelProfile: name => session.activateModelProfileForControl(name),
|
|
118
119
|
cycleThinkingLevel: () => session.cycleThinkingLevel(),
|
|
119
120
|
setQueueMode: (kind, mode) => {
|
|
120
121
|
if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) {
|
|
@@ -34,7 +34,7 @@ Explore just enough context, implement the smallest correct change, and leave co
|
|
|
34
34
|
{{#if ultragoalRedTeam}}
|
|
35
35
|
|
|
36
36
|
<ultragoal_red_team_mode>
|
|
37
|
-
This mode activates only when the assignment explicitly labels Executor as Ultragoal completion QA/red-team or asks for `executorQa` red-team evidence. Otherwise, preserve ordinary Executor behavior.
|
|
37
|
+
This mode activates only when the task sets typed `executionMode: "ultragoal-red-team"`, or — as a compatibility fallback — when the assignment text explicitly labels Executor as Ultragoal completion QA/red-team or asks for `executorQa` red-team evidence (not a bare `executorQa` field-name mention). Prefer the typed flag. Otherwise, preserve ordinary Executor behavior.
|
|
38
38
|
|
|
39
39
|
When active:
|
|
40
40
|
- Follow the exact `executorQa` contract provided in the assignment (matrix shape, row fields, artifact/replay rules); the runtime validates it strictly. If the assignment omits the contract, read the ultragoal SKILL's executor QA section before producing evidence.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MemoryGuardDomainSnapshot,
|
|
3
|
+
MemoryGuardWorkerAccounting,
|
|
4
|
+
MemoryGuardWorkerSample,
|
|
5
|
+
} from "./memory-guard-contract";
|
|
6
|
+
|
|
7
|
+
export interface MemoryGuardDomainInput {
|
|
8
|
+
effectiveLimitBytes: number;
|
|
9
|
+
totalUsageBytes: number;
|
|
10
|
+
parentBytes: number;
|
|
11
|
+
parentReserveBytes: number;
|
|
12
|
+
workers: readonly MemoryGuardWorkerSample[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertNonNegativeSafeInteger(name: string, value: number): number {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_${name}`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sumWorkerBytes(workers: readonly MemoryGuardWorkerSample[]): number {
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (const worker of workers) {
|
|
23
|
+
total += assertNonNegativeSafeInteger(`worker_bytes:${worker.workerId}`, worker.bytes);
|
|
24
|
+
if (!Number.isSafeInteger(total)) throw new Error("invalid_worker_total_bytes");
|
|
25
|
+
}
|
|
26
|
+
return total;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function computeMemoryGuardDomain(input: MemoryGuardDomainInput): MemoryGuardDomainSnapshot {
|
|
30
|
+
const effectiveLimitBytes = assertNonNegativeSafeInteger("effective_limit_bytes", input.effectiveLimitBytes);
|
|
31
|
+
const totalUsageBytes = assertNonNegativeSafeInteger("total_usage_bytes", input.totalUsageBytes);
|
|
32
|
+
const parentBytes = assertNonNegativeSafeInteger("parent_bytes", input.parentBytes);
|
|
33
|
+
const parentReserveBytes = assertNonNegativeSafeInteger("parent_reserve_bytes", input.parentReserveBytes);
|
|
34
|
+
const totalWorkerBytes = sumWorkerBytes(input.workers);
|
|
35
|
+
const acceptedWorkers = input.workers.filter(worker => worker.accepted !== false);
|
|
36
|
+
const acceptedWorkerBytes = sumWorkerBytes(acceptedWorkers);
|
|
37
|
+
const acceptedWorkerCount = acceptedWorkers.length;
|
|
38
|
+
const unmanagedBytes = Math.max(0, totalUsageBytes - parentBytes - acceptedWorkerBytes);
|
|
39
|
+
const headroomBytes = Math.max(0, effectiveLimitBytes - totalUsageBytes);
|
|
40
|
+
const workerBudgetBytes = Math.max(0, effectiveLimitBytes - unmanagedBytes - parentReserveBytes);
|
|
41
|
+
const perWorkerAllowanceBytes = acceptedWorkerCount === 0 ? 0 : workerBudgetBytes / acceptedWorkerCount;
|
|
42
|
+
const workers: MemoryGuardWorkerAccounting[] = input.workers.map(worker => {
|
|
43
|
+
const accepted = worker.accepted !== false;
|
|
44
|
+
return {
|
|
45
|
+
workerId: worker.workerId,
|
|
46
|
+
bytes: worker.bytes,
|
|
47
|
+
accepted,
|
|
48
|
+
allowanceBytes: accepted ? perWorkerAllowanceBytes : 0,
|
|
49
|
+
excessBytes: accepted ? Math.max(0, worker.bytes - perWorkerAllowanceBytes) : 0,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
effectiveLimitBytes,
|
|
54
|
+
totalUsageBytes,
|
|
55
|
+
parentBytes,
|
|
56
|
+
parentReserveBytes,
|
|
57
|
+
totalWorkerBytes,
|
|
58
|
+
acceptedWorkerCount,
|
|
59
|
+
unmanagedBytes,
|
|
60
|
+
headroomBytes,
|
|
61
|
+
workerBudgetBytes,
|
|
62
|
+
perWorkerAllowanceBytes,
|
|
63
|
+
hostExcessBytes: Math.max(0, parentBytes - parentReserveBytes),
|
|
64
|
+
workers,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface MemoryGuardPolicy {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
checkIntervalMs: number;
|
|
4
|
+
gcThresholdRatio: number;
|
|
5
|
+
restartThresholdRatio: number;
|
|
6
|
+
restartThresholdWindowMs: number;
|
|
7
|
+
cooldownMs: number;
|
|
8
|
+
parentReserveBytes: number;
|
|
9
|
+
policyLimitBytes: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MemoryGuardWorkerSample {
|
|
13
|
+
workerId: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
accepted?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MemoryGuardWorkerAccounting {
|
|
19
|
+
workerId: string;
|
|
20
|
+
bytes: number;
|
|
21
|
+
accepted: boolean;
|
|
22
|
+
allowanceBytes: number;
|
|
23
|
+
excessBytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MemoryGuardDomainSnapshot {
|
|
27
|
+
effectiveLimitBytes: number;
|
|
28
|
+
totalUsageBytes: number;
|
|
29
|
+
parentBytes: number;
|
|
30
|
+
parentReserveBytes: number;
|
|
31
|
+
totalWorkerBytes: number;
|
|
32
|
+
acceptedWorkerCount: number;
|
|
33
|
+
unmanagedBytes: number;
|
|
34
|
+
headroomBytes: number;
|
|
35
|
+
workerBudgetBytes: number;
|
|
36
|
+
perWorkerAllowanceBytes: number;
|
|
37
|
+
hostExcessBytes: number;
|
|
38
|
+
workers: MemoryGuardWorkerAccounting[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MemoryGuardHostTarget {
|
|
42
|
+
kind: "host";
|
|
43
|
+
excessBytes: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MemoryGuardWorkerTarget {
|
|
47
|
+
kind: "worker";
|
|
48
|
+
workerId: string;
|
|
49
|
+
excessBytes: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type MemoryGuardActionTarget = MemoryGuardHostTarget | MemoryGuardWorkerTarget;
|
|
53
|
+
|
|
54
|
+
export type MemoryGuardNoopReason =
|
|
55
|
+
| "memory_guard_action_noop_unmanaged_or_within_allowance"
|
|
56
|
+
| "memory_guard_action_noop_unsupported";
|
|
57
|
+
|
|
58
|
+
export type MemoryGuardDecision =
|
|
59
|
+
| { kind: "execute"; target: MemoryGuardActionTarget }
|
|
60
|
+
| { kind: "noop"; reason: MemoryGuardNoopReason }
|
|
61
|
+
| { kind: "revalidated_out"; reason: "memory_guard_action_revalidated_out" };
|
|
62
|
+
|
|
63
|
+
export interface MemoryGuardSchedulerState {
|
|
64
|
+
timerActive: boolean;
|
|
65
|
+
registrationCount: number;
|
|
66
|
+
inProgress: boolean;
|
|
67
|
+
generation: number;
|
|
68
|
+
pendingDeadline: number | null;
|
|
69
|
+
pendingOwner: { generation: number; token: number } | null;
|
|
70
|
+
deferredDeadline: number | null;
|
|
71
|
+
deferredGeneration: number | null;
|
|
72
|
+
activeGeneration: number | null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { Settings } from "../config/settings";
|
|
2
|
+
import type {
|
|
3
|
+
MemoryGuardActionTarget,
|
|
4
|
+
MemoryGuardDecision,
|
|
5
|
+
MemoryGuardDomainSnapshot,
|
|
6
|
+
MemoryGuardPolicy,
|
|
7
|
+
MemoryGuardSchedulerState,
|
|
8
|
+
} from "./memory-guard-contract";
|
|
9
|
+
|
|
10
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
11
|
+
const DEFAULT_CHECK_INTERVAL_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
type ScheduleOwner = { generation: number; token: number };
|
|
14
|
+
type DeferredSchedule = { generation: number; deadline: number };
|
|
15
|
+
type WorkOwner = { generation: number; source: "timer" | "external" };
|
|
16
|
+
|
|
17
|
+
export interface MemoryGuardHostRegistration {
|
|
18
|
+
ownerId: string;
|
|
19
|
+
intervalMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MemoryGuardHostOptions {
|
|
23
|
+
run: () => Promise<void>;
|
|
24
|
+
logDebug?: (message: string, meta?: Record<string, unknown>) => void;
|
|
25
|
+
schedulerNow?: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizePositiveIntervalMs(intervalMs: number): number {
|
|
29
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) throw new Error("invalid_interval_ms");
|
|
30
|
+
return intervalMs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toRatio(percent: number): number {
|
|
34
|
+
return percent / 100;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mbToBytes(value: number): number {
|
|
38
|
+
return Math.round(value * BYTES_PER_MB);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortTargets(left: MemoryGuardActionTarget, right: MemoryGuardActionTarget): number {
|
|
42
|
+
if (left.excessBytes !== right.excessBytes) return right.excessBytes - left.excessBytes;
|
|
43
|
+
if (left.kind !== right.kind) return left.kind === "host" ? -1 : 1;
|
|
44
|
+
if (left.kind === "worker" && right.kind === "worker") return left.workerId.localeCompare(right.workerId);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveMemoryGuardPolicy(settings: Settings): MemoryGuardPolicy {
|
|
49
|
+
const configuredPolicyLimitMb = settings.get("memoryGuard.policyLimitMb");
|
|
50
|
+
return {
|
|
51
|
+
enabled: settings.get("memoryGuard.enabled"),
|
|
52
|
+
checkIntervalMs: settings.get("memoryGuard.checkIntervalMs"),
|
|
53
|
+
gcThresholdRatio: toRatio(settings.get("memoryGuard.gcThresholdPercent")),
|
|
54
|
+
restartThresholdRatio: toRatio(settings.get("memoryGuard.restartThresholdPercent")),
|
|
55
|
+
restartThresholdWindowMs: settings.get("memoryGuard.restartThresholdWindowMs"),
|
|
56
|
+
cooldownMs: settings.get("memoryGuard.cooldownMs"),
|
|
57
|
+
parentReserveBytes: mbToBytes(settings.get("memoryGuard.parentReserveMb")),
|
|
58
|
+
policyLimitBytes: configuredPolicyLimitMb > 0 ? mbToBytes(configuredPolicyLimitMb) : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function chooseMemoryGuardAction(input: {
|
|
63
|
+
domain: MemoryGuardDomainSnapshot;
|
|
64
|
+
hostSupported: boolean;
|
|
65
|
+
workerSupported: (workerId: string) => boolean;
|
|
66
|
+
}): MemoryGuardDecision {
|
|
67
|
+
const candidates: MemoryGuardActionTarget[] = [];
|
|
68
|
+
if (input.hostSupported && input.domain.hostExcessBytes > 0) {
|
|
69
|
+
candidates.push({ kind: "host", excessBytes: input.domain.hostExcessBytes });
|
|
70
|
+
}
|
|
71
|
+
for (const worker of input.domain.workers) {
|
|
72
|
+
if (!worker.accepted || worker.excessBytes <= 0 || !input.workerSupported(worker.workerId)) continue;
|
|
73
|
+
candidates.push({ kind: "worker", workerId: worker.workerId, excessBytes: worker.excessBytes });
|
|
74
|
+
}
|
|
75
|
+
if (candidates.length > 0) {
|
|
76
|
+
candidates.sort(sortTargets);
|
|
77
|
+
return { kind: "execute", target: candidates[0]! };
|
|
78
|
+
}
|
|
79
|
+
const unsupportedExcessExists =
|
|
80
|
+
input.domain.hostExcessBytes > 0 ||
|
|
81
|
+
input.domain.workers.some(worker => worker.accepted && worker.excessBytes > 0);
|
|
82
|
+
if (unsupportedExcessExists) return { kind: "noop", reason: "memory_guard_action_noop_unsupported" };
|
|
83
|
+
return { kind: "noop", reason: "memory_guard_action_noop_unmanaged_or_within_allowance" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function revalidateMemoryGuardAction(
|
|
87
|
+
previous: Extract<MemoryGuardDecision, { kind: "execute" }>,
|
|
88
|
+
revalidated: MemoryGuardDecision,
|
|
89
|
+
): MemoryGuardDecision {
|
|
90
|
+
if (revalidated.kind !== "execute")
|
|
91
|
+
return { kind: "revalidated_out", reason: "memory_guard_action_revalidated_out" };
|
|
92
|
+
if (previous.target.kind !== revalidated.target.kind)
|
|
93
|
+
return { kind: "revalidated_out", reason: "memory_guard_action_revalidated_out" };
|
|
94
|
+
if (
|
|
95
|
+
previous.target.kind === "worker" &&
|
|
96
|
+
revalidated.target.kind === "worker" &&
|
|
97
|
+
previous.target.workerId !== revalidated.target.workerId
|
|
98
|
+
) {
|
|
99
|
+
return { kind: "revalidated_out", reason: "memory_guard_action_revalidated_out" };
|
|
100
|
+
}
|
|
101
|
+
return revalidated;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class MemoryGuardHost {
|
|
105
|
+
#run: () => Promise<void>;
|
|
106
|
+
#logDebug: (message: string, meta?: Record<string, unknown>) => void;
|
|
107
|
+
#defaultSchedulerNow: () => number;
|
|
108
|
+
#schedulerNow: () => number;
|
|
109
|
+
#registrations = new Map<string, { intervalMs: number; nextDueMs: number }>();
|
|
110
|
+
#pendingTimer: NodeJS.Timeout | null = null;
|
|
111
|
+
#pendingDeadline: number | null = null;
|
|
112
|
+
#pendingOwner: ScheduleOwner | null = null;
|
|
113
|
+
#deferredSchedule: DeferredSchedule | null = null;
|
|
114
|
+
#inProgressOwner: WorkOwner | null = null;
|
|
115
|
+
#stopped = false;
|
|
116
|
+
#generation = 0;
|
|
117
|
+
#nextTimerToken = 0;
|
|
118
|
+
|
|
119
|
+
constructor(options: MemoryGuardHostOptions) {
|
|
120
|
+
this.#run = options.run;
|
|
121
|
+
this.#logDebug = options.logDebug ?? (() => undefined);
|
|
122
|
+
this.#defaultSchedulerNow = options.schedulerNow ?? (() => performance.now());
|
|
123
|
+
this.#schedulerNow = this.#defaultSchedulerNow;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
register(registration: MemoryGuardHostRegistration): () => void {
|
|
127
|
+
const intervalMs = normalizePositiveIntervalMs(registration.intervalMs);
|
|
128
|
+
const now = this.#schedulerNow();
|
|
129
|
+
const existing = this.#registrations.get(registration.ownerId);
|
|
130
|
+
const isNewRegistration = !existing;
|
|
131
|
+
const nextDueMs = existing ? Math.min(existing.nextDueMs, now + intervalMs) : now + intervalMs;
|
|
132
|
+
this.#registrations.set(registration.ownerId, { intervalMs, nextDueMs });
|
|
133
|
+
this.#stopped = false;
|
|
134
|
+
if (isNewRegistration) {
|
|
135
|
+
const deadline = nextDueMs;
|
|
136
|
+
if (
|
|
137
|
+
this.#inProgressOwner?.generation === this.#generation &&
|
|
138
|
+
(this.#pendingDeadline === null || this.#pendingDeadline <= deadline)
|
|
139
|
+
) {
|
|
140
|
+
this.#deferSchedule(this.#generation, deadline);
|
|
141
|
+
} else {
|
|
142
|
+
this.#requestSchedule(deadline);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let unregistered = false;
|
|
146
|
+
return () => {
|
|
147
|
+
if (unregistered) return;
|
|
148
|
+
unregistered = true;
|
|
149
|
+
this.#registrations.delete(registration.ownerId);
|
|
150
|
+
if (this.#registrations.size === 0) this.#stop();
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
updateInterval(ownerId: string, intervalMs: number): void {
|
|
155
|
+
const existing = this.#registrations.get(ownerId);
|
|
156
|
+
if (!existing) return;
|
|
157
|
+
const normalized = normalizePositiveIntervalMs(intervalMs);
|
|
158
|
+
if (existing.intervalMs === normalized) return;
|
|
159
|
+
const now = this.#schedulerNow();
|
|
160
|
+
const nextDueMs = now + normalized;
|
|
161
|
+
this.#registrations.set(ownerId, { intervalMs: normalized, nextDueMs });
|
|
162
|
+
if (this.#inProgressOwner) return;
|
|
163
|
+
const nextDeadline = this.#nextDeadlineMs();
|
|
164
|
+
if (this.#pendingDeadline !== null && nextDeadline > this.#pendingDeadline) {
|
|
165
|
+
this.#clearPendingSchedule();
|
|
166
|
+
}
|
|
167
|
+
this.#requestSchedule(nextDeadline);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async runTick(generation = this.#generation, source: WorkOwner["source"] = "external"): Promise<void> {
|
|
171
|
+
if (this.#inProgressOwner || this.#registrations.size === 0) return;
|
|
172
|
+
const owner: WorkOwner = { generation, source };
|
|
173
|
+
this.#inProgressOwner = owner;
|
|
174
|
+
try {
|
|
175
|
+
await this.#run();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
this.#logDebug("memory guard tick failed", { error: error instanceof Error ? error.message : String(error) });
|
|
178
|
+
} finally {
|
|
179
|
+
if (this.#inProgressOwner === owner) this.#inProgressOwner = null;
|
|
180
|
+
this.#advanceDueTimes();
|
|
181
|
+
this.#reconcileCurrentSchedule();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async runTimerCallbackForTest(owner: { generation: number; token: number }, deadline: number): Promise<void> {
|
|
186
|
+
await this.#handleTimerCallback(owner, deadline);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setSchedulerNowForTest(now: () => number): void {
|
|
190
|
+
this.#schedulerNow = now;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getStateForTest(): MemoryGuardSchedulerState {
|
|
194
|
+
return {
|
|
195
|
+
timerActive: this.#pendingTimer !== null,
|
|
196
|
+
registrationCount: this.#registrations.size,
|
|
197
|
+
inProgress: this.#inProgressOwner !== null,
|
|
198
|
+
generation: this.#generation,
|
|
199
|
+
pendingDeadline: this.#pendingDeadline,
|
|
200
|
+
pendingOwner: this.#pendingOwner ? { ...this.#pendingOwner } : null,
|
|
201
|
+
deferredDeadline: this.#deferredSchedule?.deadline ?? null,
|
|
202
|
+
deferredGeneration: this.#deferredSchedule?.generation ?? null,
|
|
203
|
+
activeGeneration: this.#inProgressOwner?.generation ?? null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
resetForTest(): void {
|
|
208
|
+
this.#stop();
|
|
209
|
+
this.#registrations.clear();
|
|
210
|
+
this.#inProgressOwner = null;
|
|
211
|
+
this.#deferredSchedule = null;
|
|
212
|
+
this.#nextTimerToken = 0;
|
|
213
|
+
this.#schedulerNow = this.#defaultSchedulerNow;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#nextDeadlineMs(): number {
|
|
217
|
+
let minDue = Number.POSITIVE_INFINITY;
|
|
218
|
+
for (const reg of this.#registrations.values()) {
|
|
219
|
+
minDue = Math.min(minDue, reg.nextDueMs);
|
|
220
|
+
}
|
|
221
|
+
return Number.isFinite(minDue) ? minDue : this.#schedulerNow() + DEFAULT_CHECK_INTERVAL_MS;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#advanceDueTimes(): void {
|
|
225
|
+
const now = this.#schedulerNow();
|
|
226
|
+
for (const reg of this.#registrations.values()) {
|
|
227
|
+
if (reg.nextDueMs <= now) {
|
|
228
|
+
reg.nextDueMs = now + reg.intervalMs;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#clearPendingSchedule(): void {
|
|
234
|
+
if (this.#pendingTimer) clearTimeout(this.#pendingTimer);
|
|
235
|
+
this.#pendingTimer = null;
|
|
236
|
+
this.#pendingDeadline = null;
|
|
237
|
+
this.#pendingOwner = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#requestSchedule(deadline: number): void {
|
|
241
|
+
if (this.#stopped || this.#registrations.size === 0) return;
|
|
242
|
+
if (this.#pendingDeadline !== null && this.#pendingDeadline <= deadline) return;
|
|
243
|
+
this.#clearPendingSchedule();
|
|
244
|
+
const owner = { generation: this.#generation, token: ++this.#nextTimerToken };
|
|
245
|
+
this.#pendingDeadline = deadline;
|
|
246
|
+
this.#pendingOwner = owner;
|
|
247
|
+
this.#pendingTimer = setTimeout(
|
|
248
|
+
() => {
|
|
249
|
+
void this.#handleTimerCallback(owner, deadline);
|
|
250
|
+
},
|
|
251
|
+
Math.max(0, deadline - this.#schedulerNow()),
|
|
252
|
+
);
|
|
253
|
+
this.#pendingTimer.unref?.();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#deferSchedule(generation: number, deadline: number): void {
|
|
257
|
+
if (generation !== this.#generation) return;
|
|
258
|
+
if (this.#deferredSchedule?.generation === generation) {
|
|
259
|
+
this.#deferredSchedule.deadline = Math.min(this.#deferredSchedule.deadline, deadline);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.#deferredSchedule = { generation, deadline };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #handleTimerCallback(owner: ScheduleOwner, deadline: number): Promise<void> {
|
|
266
|
+
if (this.#pendingOwner?.generation !== owner.generation || this.#pendingOwner.token !== owner.token) return;
|
|
267
|
+
this.#pendingTimer = null;
|
|
268
|
+
this.#pendingDeadline = null;
|
|
269
|
+
this.#pendingOwner = null;
|
|
270
|
+
if (this.#inProgressOwner) {
|
|
271
|
+
this.#deferSchedule(owner.generation, deadline);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await this.runTick(owner.generation, "timer");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#reconcileCurrentSchedule(): void {
|
|
278
|
+
if (this.#stopped || this.#registrations.size === 0 || this.#inProgressOwner) return;
|
|
279
|
+
const normalDeadline = this.#nextDeadlineMs();
|
|
280
|
+
const deferredDeadline =
|
|
281
|
+
this.#deferredSchedule?.generation === this.#generation ? this.#deferredSchedule.deadline : null;
|
|
282
|
+
if (deferredDeadline !== null) this.#deferredSchedule = null;
|
|
283
|
+
this.#requestSchedule(deferredDeadline === null ? normalDeadline : Math.min(deferredDeadline, normalDeadline));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#stop(): void {
|
|
287
|
+
this.#stopped = true;
|
|
288
|
+
this.#generation++;
|
|
289
|
+
this.#clearPendingSchedule();
|
|
290
|
+
this.#deferredSchedule = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface EffectiveMemoryLimitInput {
|
|
2
|
+
hardCapBytes?: number | null;
|
|
3
|
+
policyLimitBytes?: number | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface EffectiveMemoryLimit {
|
|
7
|
+
hardCapBytes: number | null;
|
|
8
|
+
policyLimitBytes: number | null;
|
|
9
|
+
effectiveBytes: number | null;
|
|
10
|
+
source: "none" | "hard_cap" | "policy_limit" | "hard_cap_and_policy_limit";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizePositiveByteCount(value: number | null | undefined): number | null {
|
|
14
|
+
return Number.isSafeInteger(value) && typeof value === "number" && value > 0 ? value : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveEffectiveMemoryLimit(input: EffectiveMemoryLimitInput): EffectiveMemoryLimit {
|
|
18
|
+
const hardCapBytes = normalizePositiveByteCount(input.hardCapBytes);
|
|
19
|
+
const policyLimitBytes = normalizePositiveByteCount(input.policyLimitBytes);
|
|
20
|
+
if (hardCapBytes !== null && policyLimitBytes !== null) {
|
|
21
|
+
return {
|
|
22
|
+
hardCapBytes,
|
|
23
|
+
policyLimitBytes,
|
|
24
|
+
effectiveBytes: Math.min(hardCapBytes, policyLimitBytes),
|
|
25
|
+
source: "hard_cap_and_policy_limit",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (hardCapBytes !== null) {
|
|
29
|
+
return {
|
|
30
|
+
hardCapBytes,
|
|
31
|
+
policyLimitBytes: null,
|
|
32
|
+
effectiveBytes: hardCapBytes,
|
|
33
|
+
source: "hard_cap",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (policyLimitBytes !== null) {
|
|
37
|
+
return {
|
|
38
|
+
hardCapBytes: null,
|
|
39
|
+
policyLimitBytes,
|
|
40
|
+
effectiveBytes: policyLimitBytes,
|
|
41
|
+
source: "policy_limit",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
hardCapBytes: null,
|
|
46
|
+
policyLimitBytes: null,
|
|
47
|
+
effectiveBytes: null,
|
|
48
|
+
source: "none",
|
|
49
|
+
};
|
|
50
|
+
}
|