@akira-tl/forgerelay 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +9 -0
- package/dist/logger.js +16 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +234 -26
- package/dist/workspace-store.js +148 -20
- package/dist/workspaces.js +339 -111
- package/docs/chatgpt-coding-workflow.md +75 -12
- package/docs/configuration.md +47 -9
- package/docs/roadmap.md +23 -1
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +60 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.7] - 2026-08-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added debug-only runtime resource telemetry for RSS/V8 heap, MCP transport count, running/completed process counts, cached workspaces, and review checkpoint state so long-running deployments can identify which resource class is growing.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Background `bash` completion state is now retained for at most five minutes, completed process handles are released immediately, completed notices are globally bounded, active processes have a global concurrency budget, and per-process retained output is smaller. High-output head/tail truncation no longer materializes whole strings as Unicode code-point arrays, sharply reducing transient heap growth and GC pressure.
|
|
16
|
+
- Abandoned MCP transport sessions and in-memory review checkpoint states now have hard capacity limits. Review state is also released when its logical workspace closes, while persisted Git checkpoint refs remain available for reconstruction.
|
|
17
|
+
- Workspace instruction discovery is now bounded and demand-driven: `open_workspace` scans only the workspace root and direct child directories, while deeper `AGENTS.md` / `CLAUDE.md` files are discovered along paths as the Agent first accesses them. Reads surface newly discovered instructions inline; mutation and shell calls stop before side effects and require a retry after newly discovered local instructions are applied.
|
|
18
|
+
- Workspace/session activity timestamps now use a small in-process write-behind cache. Hot `lastUsedAt` touches are coalesced and flushed to SQLite in one transaction at most every five minutes, with an explicit final flush during normal shutdown; semantic create/close/status writes remain immediate.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Expired, never-redeemed OAuth authorization codes are opportunistically evicted and cleared on provider shutdown instead of remaining in memory for the lifetime of the server.
|
|
23
|
+
|
|
24
|
+
## [0.3.6] - 2026-08-10
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `open_workspace(action="list")` now provides paginated logical-workspace inventory without adding a tenth Core tool. Inventory entries include a compact `project/workspaceId` label, persisted status, derived lifecycle state, checkout/worktree backing metadata, creation/last-used timestamps, idle duration, root validity, current-conversation selection, and filters for workspace ID, status, state, mode, root, and stale-only views.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Workspace bootstrap context is now deduplicated by conversation scope, canonical workspace target, and a content fingerprint instead of by logical `workspaceId`. `context="auto"` remains the default, `context="full"` forces a refresh, and `context="none"` opens or resumes a workspace without returning the full AGENTS/Skills/guide/profile bootstrap.
|
|
33
|
+
- Context-delivery state is persisted independently from logical-workspace selection, so switching or closing one logical handle does not make the same conversation forget already-delivered project context. Changes to loaded instruction contents or relevant Skill, guide, profile, diagnostic, or nested-instruction metadata change the fingerprint and cause `auto` to deliver the refreshed context again.
|
|
34
|
+
- Workspace inventory is read-only with respect to workspace activity timestamps and runs the existing idle-session GC before listing. Persisted `status="active"` continues to mean the session has not been explicitly closed, while the derived `state` distinguishes currently active, stale-but-valid, invalid/missing-root, and closed records.
|
|
35
|
+
|
|
7
36
|
## [0.3.5] - 2026-08-10
|
|
8
37
|
|
|
9
38
|
### Changed
|
|
@@ -28,9 +28,9 @@ processId: <number>
|
|
|
28
28
|
|
|
29
29
|
`action="run"` 与 `action="process"` 的参数不要混用。Process ownership 始终绑定原 `workspaceId`;未知或跨 workspace 的 `processId` 会被拒绝。
|
|
30
30
|
|
|
31
|
-
等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result
|
|
31
|
+
等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result。未消费的 completed process notice 最多保留 5 分钟;进程退出时底层 ChildProcess/PTY handle 会立即释放。ForgeRelay 同时对 active process 与 completed notice 数量设置全局资源预算,达到 active process 上限时会拒绝启动新的命令,而不会擅自终止已有长任务。
|
|
32
32
|
|
|
33
|
-
不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll
|
|
33
|
+
不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll。高输出命令会使用有界 head/tail buffer,不能把 ForgeRelay 当作无限历史日志存储。
|
|
34
34
|
|
|
35
35
|
## PTY / interactive commands
|
|
36
36
|
|
package/dist/db/migrations.js
CHANGED
|
@@ -29,6 +29,11 @@ const migrations = [
|
|
|
29
29
|
name: "local-agent-hook-reports",
|
|
30
30
|
up: migrateLocalAgentHookReports,
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
version: 7,
|
|
34
|
+
name: "workspace-context-deliveries",
|
|
35
|
+
up: migrateWorkspaceContextDeliveries,
|
|
36
|
+
},
|
|
32
37
|
];
|
|
33
38
|
export function migrateDatabase(sqlite) {
|
|
34
39
|
const migrate = sqlite.transaction(() => {
|
|
@@ -189,6 +194,20 @@ function migrateWorkspaceWorktreeBranches(sqlite) {
|
|
|
189
194
|
function migrateLocalAgentHookReports(sqlite) {
|
|
190
195
|
addColumnIfMissing(sqlite, "local_agent_sessions", "hook_reports_json", "text");
|
|
191
196
|
}
|
|
197
|
+
function migrateWorkspaceContextDeliveries(sqlite) {
|
|
198
|
+
sqlite.exec(`
|
|
199
|
+
create table if not exists workspace_context_deliveries (
|
|
200
|
+
conversation_scope_id text not null,
|
|
201
|
+
target_key text not null,
|
|
202
|
+
context_fingerprint text not null,
|
|
203
|
+
delivered_at text not null,
|
|
204
|
+
primary key (conversation_scope_id, target_key)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
create index if not exists workspace_context_deliveries_delivered_idx
|
|
208
|
+
on workspace_context_deliveries(delivered_at desc);
|
|
209
|
+
`);
|
|
210
|
+
}
|
|
192
211
|
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
193
212
|
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
194
213
|
if (columns.some((existingColumn) => existingColumn.name === column))
|
package/dist/db/schema.js
CHANGED
|
@@ -41,6 +41,15 @@ export const workspaceConversationBindings = sqliteTable("workspace_conversation
|
|
|
41
41
|
primaryKey({ columns: [table.conversationScopeId, table.targetKey] }),
|
|
42
42
|
index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId),
|
|
43
43
|
]);
|
|
44
|
+
export const workspaceContextDeliveries = sqliteTable("workspace_context_deliveries", {
|
|
45
|
+
conversationScopeId: text("conversation_scope_id").notNull(),
|
|
46
|
+
targetKey: text("target_key").notNull(),
|
|
47
|
+
contextFingerprint: text("context_fingerprint").notNull(),
|
|
48
|
+
deliveredAt: text("delivered_at").notNull(),
|
|
49
|
+
}, (table) => [
|
|
50
|
+
primaryKey({ columns: [table.conversationScopeId, table.targetKey] }),
|
|
51
|
+
index("workspace_context_deliveries_delivered_idx").on(table.deliveredAt),
|
|
52
|
+
]);
|
|
44
53
|
export const oauthClients = sqliteTable("oauth_clients", {
|
|
45
54
|
clientId: text("client_id").primaryKey(),
|
|
46
55
|
clientJson: text("client_json").notNull(),
|
package/dist/logger.js
CHANGED
|
@@ -113,6 +113,8 @@ function formatPrettyMessage(entry, options) {
|
|
|
113
113
|
case "mcp_transport_session_close_failed":
|
|
114
114
|
case "mcp_session_close_failed":
|
|
115
115
|
return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
|
|
116
|
+
case "runtime_resources":
|
|
117
|
+
return formatRuntimeResources(entry);
|
|
116
118
|
case "auth_denied":
|
|
117
119
|
return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
|
|
118
120
|
case "mcp_request_error":
|
|
@@ -212,6 +214,20 @@ function stableColorIndex(value) {
|
|
|
212
214
|
}
|
|
213
215
|
return (hash >>> 0) % WORKSPACE_PROJECT_COLORS.length;
|
|
214
216
|
}
|
|
217
|
+
function formatRuntimeResources(entry) {
|
|
218
|
+
const rssMb = bytesToMegabytes(numberField(entry.rssBytes));
|
|
219
|
+
const heapUsedMb = bytesToMegabytes(numberField(entry.heapUsedBytes));
|
|
220
|
+
const heapTotalMb = bytesToMegabytes(numberField(entry.heapTotalBytes));
|
|
221
|
+
const transports = numberField(entry.mcpTransports) ?? 0;
|
|
222
|
+
const running = numberField(entry.processesRunning) ?? 0;
|
|
223
|
+
const completed = numberField(entry.processesCompleted) ?? 0;
|
|
224
|
+
const workspaces = numberField(entry.cachedWorkspaces) ?? 0;
|
|
225
|
+
const reviewStates = numberField(entry.reviewStates) ?? 0;
|
|
226
|
+
return `runtime rss=${rssMb}MB heap=${heapUsedMb}/${heapTotalMb}MB transports=${transports} processes=${running} running/${completed} completed workspaces=${workspaces} review=${reviewStates}`;
|
|
227
|
+
}
|
|
228
|
+
function bytesToMegabytes(value) {
|
|
229
|
+
return value === undefined ? 0 : Math.round(value / (1024 * 1024));
|
|
230
|
+
}
|
|
215
231
|
function formatGenericMessage(entry) {
|
|
216
232
|
const event = String(entry.event ?? "log");
|
|
217
233
|
const detail = [entry.reason, entry.error]
|
package/dist/mcp-sessions.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export class McpTransportRegistry {
|
|
2
2
|
transports = new Map();
|
|
3
3
|
now;
|
|
4
|
+
maxTransports;
|
|
4
5
|
constructor(options = {}) {
|
|
5
6
|
this.now = options.now ?? Date.now;
|
|
7
|
+
this.maxTransports = options.maxTransports ?? Number.POSITIVE_INFINITY;
|
|
8
|
+
if (this.maxTransports !== Number.POSITIVE_INFINITY &&
|
|
9
|
+
(!Number.isInteger(this.maxTransports) || this.maxTransports < 1)) {
|
|
10
|
+
throw new Error("MCP transport limit must be a positive integer.");
|
|
11
|
+
}
|
|
6
12
|
}
|
|
7
13
|
get size() {
|
|
8
14
|
return this.transports.size;
|
|
@@ -12,6 +18,30 @@ export class McpTransportRegistry {
|
|
|
12
18
|
transport,
|
|
13
19
|
lastActivityAt: this.now(),
|
|
14
20
|
});
|
|
21
|
+
const excess = [];
|
|
22
|
+
while (this.transports.size > this.maxTransports) {
|
|
23
|
+
let oldestTransportSessionId;
|
|
24
|
+
let oldestActivityAt = Number.POSITIVE_INFINITY;
|
|
25
|
+
for (const [candidateSessionId, entry] of this.transports) {
|
|
26
|
+
if (candidateSessionId === transportSessionId && this.transports.size > 1)
|
|
27
|
+
continue;
|
|
28
|
+
if (entry.lastActivityAt >= oldestActivityAt)
|
|
29
|
+
continue;
|
|
30
|
+
oldestTransportSessionId = candidateSessionId;
|
|
31
|
+
oldestActivityAt = entry.lastActivityAt;
|
|
32
|
+
}
|
|
33
|
+
if (!oldestTransportSessionId)
|
|
34
|
+
break;
|
|
35
|
+
const oldest = this.transports.get(oldestTransportSessionId);
|
|
36
|
+
this.transports.delete(oldestTransportSessionId);
|
|
37
|
+
if (oldest) {
|
|
38
|
+
excess.push({
|
|
39
|
+
transportSessionId: oldestTransportSessionId,
|
|
40
|
+
transport: oldest.transport,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return closeTransports(excess);
|
|
15
45
|
}
|
|
16
46
|
get(transportSessionId) {
|
|
17
47
|
const entry = this.transports.get(transportSessionId);
|
package/dist/oauth-provider.js
CHANGED
|
@@ -116,6 +116,7 @@ export class SingleUserOAuthProvider {
|
|
|
116
116
|
}));
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
|
+
this.pruneExpiredAuthorizationCodes();
|
|
119
120
|
const code = `code-${randomUUID()}`;
|
|
120
121
|
this.codes.set(code, {
|
|
121
122
|
clientId: client.client_id,
|
|
@@ -177,9 +178,17 @@ export class SingleUserOAuthProvider {
|
|
|
177
178
|
this.oauthStore.deleteRefreshToken(hashed);
|
|
178
179
|
}
|
|
179
180
|
close() {
|
|
181
|
+
this.codes.clear();
|
|
180
182
|
this.oauthStore.close();
|
|
181
183
|
}
|
|
184
|
+
pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
|
|
185
|
+
for (const [code, record] of this.codes) {
|
|
186
|
+
if (record.expiresAtMs < nowMs)
|
|
187
|
+
this.codes.delete(code);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
182
190
|
validCodeRecord(client, authorizationCode) {
|
|
191
|
+
this.pruneExpiredAuthorizationCodes();
|
|
183
192
|
const record = this.codes.get(authorizationCode);
|
|
184
193
|
if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) {
|
|
185
194
|
throw new InvalidGrantError("Invalid authorization code");
|
package/dist/process-sessions.js
CHANGED
|
@@ -7,8 +7,10 @@ const MAX_START_YIELD_MS = 300_000;
|
|
|
7
7
|
const MAX_COMMAND_YIELD_MS = 300_000;
|
|
8
8
|
const MAX_POLL_YIELD_MS = 300_000;
|
|
9
9
|
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
10
|
-
const DEFAULT_BUFFER_CHARACTERS =
|
|
11
|
-
const
|
|
10
|
+
const DEFAULT_BUFFER_CHARACTERS = 256_000;
|
|
11
|
+
const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
|
|
12
|
+
const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
|
|
13
|
+
const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
|
|
12
14
|
const DEFAULT_COLUMNS = 80;
|
|
13
15
|
const DEFAULT_ROWS = 24;
|
|
14
16
|
function boundedInteger(value, fallback, maximum) {
|
|
@@ -56,21 +58,55 @@ function processEnvironment(input) {
|
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
function codePointLength(value) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
let characters = 0;
|
|
62
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
63
|
+
const codeUnit = value.charCodeAt(index);
|
|
64
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
|
|
65
|
+
index + 1 < value.length) {
|
|
66
|
+
const nextCodeUnit = value.charCodeAt(index + 1);
|
|
67
|
+
if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
characters += 1;
|
|
71
|
+
}
|
|
72
|
+
return characters;
|
|
63
73
|
}
|
|
64
74
|
function takeHead(value, count) {
|
|
65
75
|
if (count <= 0)
|
|
66
76
|
return "";
|
|
67
|
-
|
|
77
|
+
let index = 0;
|
|
78
|
+
let characters = 0;
|
|
79
|
+
while (index < value.length && characters < count) {
|
|
80
|
+
const codeUnit = value.charCodeAt(index);
|
|
81
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
|
|
82
|
+
index + 1 < value.length) {
|
|
83
|
+
const nextCodeUnit = value.charCodeAt(index + 1);
|
|
84
|
+
index += nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff ? 2 : 1;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
index += 1;
|
|
88
|
+
}
|
|
89
|
+
characters += 1;
|
|
90
|
+
}
|
|
91
|
+
return value.slice(0, index);
|
|
68
92
|
}
|
|
69
93
|
function takeTail(value, count) {
|
|
70
94
|
if (count <= 0)
|
|
71
95
|
return "";
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
let index = value.length;
|
|
97
|
+
let characters = 0;
|
|
98
|
+
while (index > 0 && characters < count) {
|
|
99
|
+
index -= 1;
|
|
100
|
+
const codeUnit = value.charCodeAt(index);
|
|
101
|
+
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff &&
|
|
102
|
+
index > 0) {
|
|
103
|
+
const previousCodeUnit = value.charCodeAt(index - 1);
|
|
104
|
+
if (previousCodeUnit >= 0xd800 && previousCodeUnit <= 0xdbff)
|
|
105
|
+
index -= 1;
|
|
106
|
+
}
|
|
107
|
+
characters += 1;
|
|
108
|
+
}
|
|
109
|
+
return value.slice(index);
|
|
74
110
|
}
|
|
75
111
|
function splitBudget(maxCharacters) {
|
|
76
112
|
return {
|
|
@@ -97,20 +133,29 @@ export class HeadTailBuffer {
|
|
|
97
133
|
append(output) {
|
|
98
134
|
if (!output)
|
|
99
135
|
return;
|
|
136
|
+
const outputCharacters = codePointLength(output);
|
|
100
137
|
const previousTotal = this.totalCharacters;
|
|
101
|
-
this.totalCharacters +=
|
|
138
|
+
this.totalCharacters += outputCharacters;
|
|
102
139
|
if (this.totalCharacters <= this.maxCharacters) {
|
|
103
140
|
this.head += output;
|
|
104
141
|
return;
|
|
105
142
|
}
|
|
106
143
|
const budget = splitBudget(this.maxCharacters);
|
|
107
144
|
if (previousTotal <= this.maxCharacters) {
|
|
108
|
-
const
|
|
109
|
-
this.head =
|
|
110
|
-
|
|
145
|
+
const previousHead = this.head;
|
|
146
|
+
this.head = previousTotal >= budget.head
|
|
147
|
+
? takeHead(previousHead, budget.head)
|
|
148
|
+
: previousHead + takeHead(output, budget.head - previousTotal);
|
|
149
|
+
this.tail = outputCharacters >= budget.tail
|
|
150
|
+
? takeTail(output, budget.tail)
|
|
151
|
+
: takeTail(previousHead, budget.tail - outputCharacters) + output;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (outputCharacters >= budget.tail) {
|
|
155
|
+
this.tail = takeTail(output, budget.tail);
|
|
111
156
|
return;
|
|
112
157
|
}
|
|
113
|
-
this.tail = takeTail(this.tail
|
|
158
|
+
this.tail = takeTail(this.tail, budget.tail - outputCharacters) + output;
|
|
114
159
|
}
|
|
115
160
|
hasOutput() {
|
|
116
161
|
return this.totalCharacters > 0;
|
|
@@ -145,13 +190,24 @@ function truncateOutput(output, maxCharacters) {
|
|
|
145
190
|
export class ProcessManager {
|
|
146
191
|
processes = new Map();
|
|
147
192
|
completedByWorkspace = new Map();
|
|
193
|
+
completedProcessIds = [];
|
|
148
194
|
maxBufferCharacters;
|
|
195
|
+
maxActiveProcesses;
|
|
196
|
+
maxCompletedProcesses;
|
|
149
197
|
completedProcessTtlMs;
|
|
150
198
|
maxStartYieldMs;
|
|
151
199
|
monotonicNow;
|
|
152
200
|
nextProcessId = 1;
|
|
153
201
|
constructor(options = {}) {
|
|
154
202
|
this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
|
|
203
|
+
this.maxActiveProcesses = options.maxActiveProcesses ?? DEFAULT_MAX_ACTIVE_PROCESSES;
|
|
204
|
+
if (!Number.isInteger(this.maxActiveProcesses) || this.maxActiveProcesses < 1) {
|
|
205
|
+
throw new Error("Active process limit must be a positive integer.");
|
|
206
|
+
}
|
|
207
|
+
this.maxCompletedProcesses = options.maxCompletedProcesses ?? DEFAULT_MAX_COMPLETED_PROCESSES;
|
|
208
|
+
if (!Number.isInteger(this.maxCompletedProcesses) || this.maxCompletedProcesses < 1) {
|
|
209
|
+
throw new Error("Completed process limit must be a positive integer.");
|
|
210
|
+
}
|
|
155
211
|
this.completedProcessTtlMs = options.completedProcessTtlMs
|
|
156
212
|
?? options.completedSessionTtlMs
|
|
157
213
|
?? COMPLETED_PROCESS_TTL_MS;
|
|
@@ -159,6 +215,9 @@ export class ProcessManager {
|
|
|
159
215
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
160
216
|
}
|
|
161
217
|
async start(input) {
|
|
218
|
+
if (this.stats().running >= this.maxActiveProcesses) {
|
|
219
|
+
throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
|
|
220
|
+
}
|
|
162
221
|
const processEntry = this.createProcess(input);
|
|
163
222
|
this.processes.set(processEntry.id, processEntry);
|
|
164
223
|
try {
|
|
@@ -214,6 +273,17 @@ export class ProcessManager {
|
|
|
214
273
|
activeWorkspaceIds() {
|
|
215
274
|
return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
|
|
216
275
|
}
|
|
276
|
+
stats() {
|
|
277
|
+
let running = 0;
|
|
278
|
+
let completed = 0;
|
|
279
|
+
for (const processEntry of this.processes.values()) {
|
|
280
|
+
if (processEntry.running)
|
|
281
|
+
running += 1;
|
|
282
|
+
else
|
|
283
|
+
completed += 1;
|
|
284
|
+
}
|
|
285
|
+
return { total: this.processes.size, running, completed };
|
|
286
|
+
}
|
|
217
287
|
takeCompleted(workspaceId, maxOutputTokens, excludeProcessId) {
|
|
218
288
|
const processIds = this.completedByWorkspace.get(workspaceId) ?? [];
|
|
219
289
|
if (processIds.length === 0)
|
|
@@ -250,6 +320,7 @@ export class ProcessManager {
|
|
|
250
320
|
}
|
|
251
321
|
this.processes.clear();
|
|
252
322
|
this.completedByWorkspace.clear();
|
|
323
|
+
this.completedProcessIds.length = 0;
|
|
253
324
|
}
|
|
254
325
|
async waitForExit(processEntry, yieldTimeMs) {
|
|
255
326
|
let timer;
|
|
@@ -352,16 +423,24 @@ export class ProcessManager {
|
|
|
352
423
|
processEntry.running = false;
|
|
353
424
|
processEntry.exitCode = exitCode;
|
|
354
425
|
processEntry.signal = signal;
|
|
426
|
+
processEntry.process = undefined;
|
|
355
427
|
processEntry.resolveExit();
|
|
428
|
+
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
|
|
429
|
+
processEntry.cleanupTimer.unref();
|
|
356
430
|
if (processEntry.background) {
|
|
357
431
|
const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
|
|
358
432
|
if (!completed.includes(processEntry.id)) {
|
|
359
433
|
completed.push(processEntry.id);
|
|
360
434
|
this.completedByWorkspace.set(processEntry.workspaceId, completed);
|
|
435
|
+
this.completedProcessIds.push(processEntry.id);
|
|
436
|
+
}
|
|
437
|
+
while (this.completedProcessIds.length > this.maxCompletedProcesses) {
|
|
438
|
+
const oldestProcessId = this.completedProcessIds[0];
|
|
439
|
+
if (oldestProcessId === undefined)
|
|
440
|
+
break;
|
|
441
|
+
this.removeProcess(oldestProcessId);
|
|
361
442
|
}
|
|
362
443
|
}
|
|
363
|
-
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
|
|
364
|
-
processEntry.cleanupTimer.unref();
|
|
365
444
|
}
|
|
366
445
|
append(processEntry, output) {
|
|
367
446
|
processEntry.buffer.append(output);
|
|
@@ -396,6 +475,9 @@ export class ProcessManager {
|
|
|
396
475
|
if (processEntry?.cleanupTimer)
|
|
397
476
|
clearTimeout(processEntry.cleanupTimer);
|
|
398
477
|
this.processes.delete(processId);
|
|
478
|
+
const completedIndex = this.completedProcessIds.indexOf(processId);
|
|
479
|
+
if (completedIndex >= 0)
|
|
480
|
+
this.completedProcessIds.splice(completedIndex, 1);
|
|
399
481
|
if (!processEntry)
|
|
400
482
|
return;
|
|
401
483
|
const completed = this.completedByWorkspace.get(processEntry.workspaceId);
|
|
@@ -3,26 +3,60 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { git, getGitEligibility, safeWorkspaceRefSegment } from "./git.js";
|
|
5
5
|
const REVIEW_REF_PREFIX = "refs/devspace/review";
|
|
6
|
-
|
|
6
|
+
const DEFAULT_MAX_REVIEW_WORKSPACE_STATES = 128;
|
|
7
|
+
export function createReviewCheckpointManager(options = {}) {
|
|
8
|
+
const maxWorkspaceStates = options.maxWorkspaceStates ?? DEFAULT_MAX_REVIEW_WORKSPACE_STATES;
|
|
9
|
+
if (!Number.isInteger(maxWorkspaceStates) || maxWorkspaceStates < 1) {
|
|
10
|
+
throw new Error("Review checkpoint workspace-state limit must be a positive integer.");
|
|
11
|
+
}
|
|
7
12
|
const states = new Map();
|
|
8
13
|
const initializations = new Map();
|
|
14
|
+
const touchState = (workspaceId) => {
|
|
15
|
+
const state = states.get(workspaceId);
|
|
16
|
+
if (!state)
|
|
17
|
+
return;
|
|
18
|
+
states.delete(workspaceId);
|
|
19
|
+
states.set(workspaceId, state);
|
|
20
|
+
};
|
|
21
|
+
const trimStates = () => {
|
|
22
|
+
while (states.size > maxWorkspaceStates) {
|
|
23
|
+
const oldestWorkspaceId = states.keys().next().value;
|
|
24
|
+
if (!oldestWorkspaceId)
|
|
25
|
+
break;
|
|
26
|
+
states.delete(oldestWorkspaceId);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
9
29
|
return {
|
|
30
|
+
get stateCount() {
|
|
31
|
+
return states.size;
|
|
32
|
+
},
|
|
33
|
+
async releaseWorkspace(workspaceId) {
|
|
34
|
+
const pending = initializations.get(workspaceId);
|
|
35
|
+
if (pending)
|
|
36
|
+
await pending;
|
|
37
|
+
states.delete(workspaceId);
|
|
38
|
+
},
|
|
10
39
|
async initializeWorkspace({ workspaceId, root }) {
|
|
11
40
|
const existingState = states.get(workspaceId);
|
|
12
41
|
assertWorkspaceRoot(existingState, workspaceId, root);
|
|
13
42
|
if (existingState?.root === root && existingState.gitRoot !== undefined) {
|
|
43
|
+
touchState(workspaceId);
|
|
14
44
|
return;
|
|
15
45
|
}
|
|
16
46
|
const pending = initializations.get(workspaceId);
|
|
17
47
|
if (pending) {
|
|
18
48
|
await pending;
|
|
19
49
|
assertWorkspaceRoot(states.get(workspaceId), workspaceId, root);
|
|
50
|
+
touchState(workspaceId);
|
|
51
|
+
trimStates();
|
|
20
52
|
return;
|
|
21
53
|
}
|
|
22
54
|
const initialize = initializeWorkspaceState(states, workspaceId, root);
|
|
23
55
|
initializations.set(workspaceId, initialize);
|
|
24
56
|
try {
|
|
25
57
|
await initialize;
|
|
58
|
+
touchState(workspaceId);
|
|
59
|
+
trimStates();
|
|
26
60
|
}
|
|
27
61
|
finally {
|
|
28
62
|
if (initializations.get(workspaceId) === initialize) {
|
|
@@ -38,6 +72,7 @@ export function createReviewCheckpointManager() {
|
|
|
38
72
|
state = states.get(workspaceId);
|
|
39
73
|
}
|
|
40
74
|
assertWorkspaceRoot(state, workspaceId, root);
|
|
75
|
+
touchState(workspaceId);
|
|
41
76
|
if (!state?.gitRoot) {
|
|
42
77
|
throw new Error(state?.diagnostic ?? "review.changes requires a Git workspace in this version.");
|
|
43
78
|
}
|