@north-light/crouter 0.3.174 → 0.3.176
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/dist/api/dto/nodes.d.ts +7 -1
- package/dist/api/dto/profiles.d.ts +3 -0
- package/dist/clients/attach/render/crtr-output.d.ts +3 -3
- package/dist/clients/attach/render/crtr-output.js +25 -13
- package/dist/clients/attach/viewer.js +531 -561
- package/dist/commands/memory/write.js +13 -12
- package/dist/commands/node/create.js +7 -3
- package/dist/commands/profile/list.js +3 -1
- package/dist/commands/profile/new.js +4 -1
- package/dist/commands/profile/project.js +23 -9
- package/dist/commands/profile/show.js +3 -1
- package/dist/commands/profile.js +2 -2
- package/dist/commands/sys/logs.js +4 -2
- package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/command-plugins/transport/http-invoke.js +10 -2
- package/dist/core/command.js +3 -1
- package/dist/core/config.js +4 -27
- package/dist/core/exclusive-lock.d.ts +14 -0
- package/dist/core/exclusive-lock.js +124 -0
- package/dist/core/human/claim.js +2 -1
- package/dist/core/human/convention.d.ts +0 -10
- package/dist/core/human/convention.js +1 -123
- package/dist/core/io.d.ts +23 -0
- package/dist/core/io.js +60 -0
- package/dist/core/preview-registry.d.ts +10 -5
- package/dist/core/preview-registry.js +136 -2537
- package/dist/core/profiles/manifest.d.ts +12 -1
- package/dist/core/profiles/manifest.js +57 -48
- package/dist/core/review/store.d.ts +4 -1
- package/dist/core/review/store.js +10 -0
- package/dist/core/runtime/branded-host.js +6 -84
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/runtime/warm-pool.d.ts +9 -8
- package/dist/core/runtime/warm-pool.js +58 -62
- package/dist/core/substrate/index.d.ts +2 -1
- package/dist/core/substrate/index.js +2 -1
- package/dist/core/substrate/on-read-node.d.ts +7 -0
- package/dist/core/substrate/on-read-node.js +37 -0
- package/dist/core/substrate/on-read.d.ts +7 -5
- package/dist/core/substrate/on-read.js +15 -23
- package/dist/core/termrender/termrender.js +16 -96
- package/dist/core/worktree.js +11 -100
- package/dist/daemon/api/handlers/nodes.js +32 -9
- package/dist/daemon/api/handlers/review-comments.js +5 -44
- package/dist/daemon/api/handlers/reviews.js +5 -53
- package/dist/daemon/api/handlers/validate.d.ts +22 -0
- package/dist/daemon/api/handlers/validate.js +59 -0
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/review/finish.js +1 -10
- package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
- package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
- package/dist/pi-extensions/canvas-preview-result.js +60 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/substrate/on-read-pure.d.ts +0 -7
- package/dist/core/substrate/on-read-pure.js +0 -272
|
@@ -7,4 +7,5 @@ export { gatePasses } from './gate.js';
|
|
|
7
7
|
export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveSystemPromptRung, } from './ceiling.js';
|
|
8
8
|
export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
|
|
9
9
|
export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
|
|
10
|
-
export {
|
|
10
|
+
export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject } from './on-read.js';
|
|
11
|
+
export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
|
|
@@ -22,4 +22,5 @@ export { gatePasses } from './gate.js';
|
|
|
22
22
|
export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveSystemPromptRung, } from './ceiling.js';
|
|
23
23
|
export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
|
|
24
24
|
export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
|
|
25
|
-
export {
|
|
25
|
+
export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject } from './on-read.js';
|
|
26
|
+
export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Surface docs matched by a successful read tool call. An unknown node id or
|
|
2
|
+
* an unreadable canvas renders nothing. */
|
|
3
|
+
export declare function renderOnReadDocs(nodeId: string, readFilePath: string, seen?: Set<string>): string;
|
|
4
|
+
/** Surface workspace-wide docs during first-message assembly, using the node's
|
|
5
|
+
* recorded cwd and selected profile. `.` is a reserved applies-to target
|
|
6
|
+
* meaning “when this project store is mounted by cwd/profile”. */
|
|
7
|
+
export declare function renderWorkspaceOpenDocs(nodeId: string): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// on-read-node.ts — the CANVAS-DB wrappers over the pure on-read functions.
|
|
2
|
+
// Both take a node id, assemble its subject from the canvas-db, then delegate
|
|
3
|
+
// to on-read.ts. Split out for the same reason as render-node.ts: a consumer
|
|
4
|
+
// that already holds a subject imports on-read.js without subject.js's
|
|
5
|
+
// canvas-db reach.
|
|
6
|
+
import { getNode } from '../canvas/index.js';
|
|
7
|
+
import { assembleNodeSubject } from './subject.js';
|
|
8
|
+
import { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject } from './on-read.js';
|
|
9
|
+
/** Surface docs matched by a successful read tool call. An unknown node id or
|
|
10
|
+
* an unreadable canvas renders nothing. */
|
|
11
|
+
export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
|
|
12
|
+
let subject;
|
|
13
|
+
try {
|
|
14
|
+
subject = assembleNodeSubject(nodeId);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
if (subject === null)
|
|
20
|
+
return '';
|
|
21
|
+
return renderOnReadDocsForSubject(subject, readFilePath, seen);
|
|
22
|
+
}
|
|
23
|
+
/** Surface workspace-wide docs during first-message assembly, using the node's
|
|
24
|
+
* recorded cwd and selected profile. `.` is a reserved applies-to target
|
|
25
|
+
* meaning “when this project store is mounted by cwd/profile”. */
|
|
26
|
+
export function renderWorkspaceOpenDocs(nodeId) {
|
|
27
|
+
try {
|
|
28
|
+
const subject = assembleNodeSubject(nodeId);
|
|
29
|
+
const node = getNode(nodeId);
|
|
30
|
+
if (subject === null || node === null)
|
|
31
|
+
return '';
|
|
32
|
+
return renderWorkspaceOpenDocsForSubject(subject, node.cwd, node.profile_id ?? null);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import type { NodeConfigSubject } from './subject-fields.js';
|
|
2
|
+
/** Surface docs matched by a successful read tool call using the caller-supplied
|
|
3
|
+
* daemon snapshot subject. */
|
|
4
|
+
export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, seen?: Set<string>): string;
|
|
5
|
+
/** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
|
|
6
|
+
* lookup. */
|
|
7
|
+
export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null): string;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// on-read.ts — file-context delivery for the document
|
|
1
|
+
// on-read.ts — the pure file-context delivery functions for the document
|
|
2
|
+
// substrate. Both take a caller-supplied subject; the canvas-db wrappers that
|
|
3
|
+
// resolve one from a node id live in on-read-node.ts, so a consumer that
|
|
4
|
+
// already holds a subject imports this module WITHOUT pulling subject.js's
|
|
5
|
+
// canvas-db reach (the same split as render.ts / render-node.ts).
|
|
2
6
|
//
|
|
3
7
|
// A memory with file-read visibility participates only through an explicit
|
|
4
8
|
// `applies-to` path glob (authoring lint requires one). Two events use that one
|
|
@@ -28,9 +32,10 @@ import { pathExists, readText, walkFiles } from '../fs-utils.js';
|
|
|
28
32
|
import { parseFrontmatterGeneric } from '../frontmatter.js';
|
|
29
33
|
import { evalCondition } from '../predicate.js';
|
|
30
34
|
import { listAllMemoryDocs, listProjectMemoryDocs } from '../memory-resolver.js';
|
|
31
|
-
import { getNode } from '../canvas/index.js';
|
|
32
35
|
import { userScopeRoot } from '../scope.js';
|
|
33
|
-
import {
|
|
36
|
+
import { displayName } from './ceiling.js';
|
|
37
|
+
import { gatePasses } from './gate.js';
|
|
38
|
+
import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName } from './schema.js';
|
|
34
39
|
import { cachedSubstrateDocs } from './session-cache.js';
|
|
35
40
|
const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
|
|
36
41
|
function realpathOrSelf(p) {
|
|
@@ -237,17 +242,9 @@ function renderCandidates(subject, docs, seen) {
|
|
|
237
242
|
? ''
|
|
238
243
|
: `<auto-loaded-context>\n${rendered.join('\n')}\n</auto-loaded-context>`;
|
|
239
244
|
}
|
|
240
|
-
/** Surface docs matched by a successful read tool call
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
subject = assembleNodeSubject(nodeId);
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
return '';
|
|
248
|
-
}
|
|
249
|
-
if (subject === null)
|
|
250
|
-
return '';
|
|
245
|
+
/** Surface docs matched by a successful read tool call using the caller-supplied
|
|
246
|
+
* daemon snapshot subject. */
|
|
247
|
+
export function renderOnReadDocsForSubject(subject, readFilePath, seen = new Set()) {
|
|
251
248
|
const absReadFile = realpathOrSelf(readFilePath);
|
|
252
249
|
const readFrontmatter = readFileFrontmatter(absReadFile);
|
|
253
250
|
const docs = dedupeByPhysicalPath([...enclosingProjectDocs(absReadFile), ...resolvedDocs()])
|
|
@@ -255,17 +252,12 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
|
|
|
255
252
|
.filter((doc) => matchesReadEvent(doc, absReadFile, readFrontmatter));
|
|
256
253
|
return renderCandidates(subject, docs, seen);
|
|
257
254
|
}
|
|
258
|
-
/**
|
|
259
|
-
*
|
|
260
|
-
export function
|
|
261
|
-
let subject;
|
|
255
|
+
/** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
|
|
256
|
+
* lookup. */
|
|
257
|
+
export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
|
|
262
258
|
let docs;
|
|
263
259
|
try {
|
|
264
|
-
|
|
265
|
-
const node = getNode(nodeId);
|
|
266
|
-
if (subject === null || node === null)
|
|
267
|
-
return '';
|
|
268
|
-
docs = listProjectMemoryDocs(node.cwd, node.profile_id ?? null)
|
|
260
|
+
docs = listProjectMemoryDocs(cwd, profileId)
|
|
269
261
|
.map(parseSubstrateDoc)
|
|
270
262
|
.filter((doc) => doc !== null);
|
|
271
263
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync,
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import stringWidth from 'string-width';
|
|
6
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
6
7
|
import { TERMRENDER_VERSION } from './version.js';
|
|
7
8
|
// ── The sole org-wide termrender binding ─────────────────────────────────────
|
|
8
9
|
//
|
|
@@ -31,15 +32,11 @@ const VENV_STAMP = join(VENV_DIR, '.crtr-termrender-stamp.json');
|
|
|
31
32
|
// in a read-only runtime image, and serializes venv mutation + stamp publication
|
|
32
33
|
// across processes: a stamp can never certify a concurrently-changing venv.
|
|
33
34
|
const VENV_LOCK = join(RENDERER_CACHE_DIR, '.crtr-termrender.lock');
|
|
34
|
-
// A lock older than this is from a crashed process and may be stolen. Set
|
|
35
|
-
// comfortably above the worst-case held path (uv probe 5s + venv 60s + install
|
|
36
|
-
// 120s + re-verify ~10s ≈ 195s) so a slow-but-alive holder is never judged
|
|
37
|
-
// stale while it still holds.
|
|
38
|
-
const LOCK_STALE_MS = 300_000;
|
|
39
35
|
// Absolute cap on how long a waiter spins before giving up to plaintext for
|
|
40
36
|
// this session (the next launch retries) — a safety valve so a wedged holder
|
|
41
|
-
// can never hang a process
|
|
42
|
-
|
|
37
|
+
// can never hang a process. Set comfortably above the worst-case held path
|
|
38
|
+
// (uv probe 5s + venv 60s + install 120s + re-verify ~10s ≈ 195s).
|
|
39
|
+
const LOCK_GIVE_UP_MS = 360_000;
|
|
43
40
|
let rendererState = 'unchecked';
|
|
44
41
|
function isFp(x) {
|
|
45
42
|
const e = x;
|
|
@@ -167,46 +164,12 @@ function stampValid() {
|
|
|
167
164
|
}
|
|
168
165
|
return true;
|
|
169
166
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (typeof at === 'number')
|
|
177
|
-
return Date.now() - at > LOCK_STALE_MS;
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
// Unreadable/malformed lock — fall through to mtime.
|
|
181
|
-
}
|
|
182
|
-
try {
|
|
183
|
-
return Date.now() - statSync(VENV_LOCK).mtimeMs > LOCK_STALE_MS;
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
return true; // vanished — the retry loop re-acquires
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
// Run `provision` while holding the exclusive provisioning lock. If another
|
|
190
|
-
// live process holds it, wait for that process to publish (re-checking the
|
|
191
|
-
// stamp) rather than mutating the venv concurrently. Stale locks are stolen.
|
|
192
|
-
// Steal a lock judged stale by renaming it to a unique name first: rename is
|
|
193
|
-
// atomic, so if two waiters race only the one whose rename succeeds owns (and
|
|
194
|
-
// deletes) it — the loser gets ENOENT and re-loops. This can never delete a
|
|
195
|
-
// lock a peer has freshly re-acquired (that peer holds a DIFFERENT inode at the
|
|
196
|
-
// same path; our rename of the old name either already happened or fails).
|
|
197
|
-
function stealStaleLock() {
|
|
198
|
-
const tmp = `${VENV_LOCK}.steal.${process.pid}.${Date.now()}`;
|
|
199
|
-
try {
|
|
200
|
-
renameSync(VENV_LOCK, tmp);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return; // lost the steal race or already gone — caller re-loops
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
unlinkSync(tmp);
|
|
207
|
-
}
|
|
208
|
-
catch { /* best effort */ }
|
|
209
|
-
}
|
|
167
|
+
// Run `provision` holding the exclusive provisioning lock, so no two processes
|
|
168
|
+
// mutate the venv (or certify it) at once. A crashed holder is reclaimed at
|
|
169
|
+
// once; a live one is waited on, and if it outlasts the give-up window this
|
|
170
|
+
// process falls back to plaintext for the session rather than break mutual
|
|
171
|
+
// exclusion. `provision` re-checks the stamp first, so a waiter that finally
|
|
172
|
+
// acquires adopts the peer's published result instead of reinstalling.
|
|
210
173
|
function withProvisionLock(provision) {
|
|
211
174
|
try {
|
|
212
175
|
mkdirSync(RENDERER_CACHE_DIR, { recursive: true });
|
|
@@ -216,54 +179,11 @@ function withProvisionLock(provision) {
|
|
|
216
179
|
process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be created (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
217
180
|
return;
|
|
218
181
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
catch (err) {
|
|
226
|
-
if (err.code !== 'EEXIST') {
|
|
227
|
-
rendererState = 'unavailable';
|
|
228
|
-
process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be locked (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (lockIsStale()) {
|
|
232
|
-
stealStaleLock();
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
// A live process is provisioning — give it a chance, then adopt its result.
|
|
236
|
-
// Never steal a lock we can't prove stale; if we wait too long, give up to
|
|
237
|
-
// plaintext this session rather than break mutual exclusion.
|
|
238
|
-
if (Date.now() > giveUpAt) {
|
|
239
|
-
rendererState = 'unavailable';
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
sleepSync(200);
|
|
243
|
-
if (stampValid()) {
|
|
244
|
-
rendererState = 'ready';
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
try {
|
|
250
|
-
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
251
|
-
}
|
|
252
|
-
catch { /* lock held regardless of whether the marker body wrote */ }
|
|
253
|
-
try {
|
|
254
|
-
provision();
|
|
255
|
-
}
|
|
256
|
-
finally {
|
|
257
|
-
try {
|
|
258
|
-
closeSync(fd);
|
|
259
|
-
}
|
|
260
|
-
catch { /* already closed */ }
|
|
261
|
-
try {
|
|
262
|
-
unlinkSync(VENV_LOCK);
|
|
263
|
-
}
|
|
264
|
-
catch { /* stolen as stale by another process */ }
|
|
265
|
-
}
|
|
266
|
-
return;
|
|
182
|
+
try {
|
|
183
|
+
withExclusiveDirectoryLock(VENV_LOCK, provision, { timeoutMs: LOCK_GIVE_UP_MS });
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
rendererState = 'unavailable';
|
|
267
187
|
}
|
|
268
188
|
}
|
|
269
189
|
function binaryOk() {
|
package/dist/core/worktree.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { createHash
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { gitSync } from './git.js';
|
|
5
5
|
import { crtrHome } from './canvas/paths.js';
|
|
6
6
|
import { getNode, updateNode } from './canvas/index.js';
|
|
7
7
|
import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
|
|
8
|
+
import { withExclusiveDirectoryLock } from './exclusive-lock.js';
|
|
8
9
|
export class WorktreeError extends Error {
|
|
9
10
|
code;
|
|
10
11
|
next;
|
|
@@ -67,113 +68,23 @@ function checkoutPathForBranch(repoRoot, branch) {
|
|
|
67
68
|
return null;
|
|
68
69
|
}
|
|
69
70
|
const WORKTREE_LOCK_WAIT_MS = 30_000;
|
|
70
|
-
const WORKTREE_LOCK_POLL_MS = 20;
|
|
71
71
|
const WORKTREE_CLEANUP_TIMEOUT_MS = 5_000;
|
|
72
|
-
function sleepSync(ms) {
|
|
73
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
74
|
-
}
|
|
75
|
-
/** Serialize worktree mutation for one repository across crtr processes. Git's
|
|
76
|
-
* own worktree metadata lock is intentionally short-lived and rejects a second
|
|
77
|
-
* add instead of waiting, so callers take this transaction lock first. */
|
|
78
72
|
function commonGitDir(repoRoot) {
|
|
79
73
|
const commonDir = runGit(repoRoot, ['rev-parse', '--git-common-dir'], 'git_common_dir_failed', 'Inspect the repository git metadata, then retry.');
|
|
80
74
|
return realpathSync(resolve(repoRoot, commonDir));
|
|
81
75
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return false;
|
|
86
|
-
try {
|
|
87
|
-
process.kill(pid, 0);
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
return err.code !== 'ESRCH';
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
function reclaimDeadLock(lockPath, incumbent) {
|
|
95
|
-
// A hard-link claim elects exactly one reclaimer without moving lockPath.
|
|
96
|
-
// Its inode remains the stale lock even if a new owner appears later.
|
|
97
|
-
const claimPath = `${lockPath}.reclaim`;
|
|
98
|
-
try {
|
|
99
|
-
linkSync(lockPath, claimPath);
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
// A crashed reclaimer can leave its claim behind. Removing that claim is
|
|
103
|
-
// safe: it names only the already-dead incumbent, and any live reclaimer
|
|
104
|
-
// then loses authority because its inode check below fails.
|
|
105
|
-
try {
|
|
106
|
-
unlinkSync(claimPath);
|
|
107
|
-
}
|
|
108
|
-
catch { /* claimant is still resolving it */ }
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
const lock = statSync(lockPath);
|
|
113
|
-
const claim = statSync(claimPath);
|
|
114
|
-
if (lock.ino === claim.ino && readFileSync(claimPath, 'utf8') === incumbent && !ownerIsAlive(incumbent)) {
|
|
115
|
-
unlinkSync(lockPath);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
catch { /* owner or another reclaimer changed state; leave it alone */ }
|
|
119
|
-
finally {
|
|
120
|
-
try {
|
|
121
|
-
unlinkSync(claimPath);
|
|
122
|
-
}
|
|
123
|
-
catch { /* a later reclaimer will clear this stale claim */ }
|
|
124
|
-
}
|
|
125
|
-
}
|
|
76
|
+
/** Serialize worktree mutation for one repository across crtr processes. Git's
|
|
77
|
+
* own worktree metadata lock is intentionally short-lived and rejects a second
|
|
78
|
+
* add instead of waiting, so callers take this transaction lock first. */
|
|
126
79
|
function withRepositoryWorktreeLock(repoRoot, action) {
|
|
127
80
|
const commonDir = commonGitDir(repoRoot);
|
|
128
81
|
const lockRoot = `${crtrHome()}/worktree-locks`;
|
|
129
|
-
const lockName = createHash('sha256').update(commonDir).digest('hex');
|
|
130
|
-
const lockPath = `${lockRoot}/${lockName}`;
|
|
131
|
-
const owner = `${process.pid}:${randomUUID()}`;
|
|
132
|
-
const tempPath = `${lockPath}.${owner}.tmp`;
|
|
133
82
|
mkdirSync(lockRoot, { recursive: true });
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
linkSync(tempPath, lockPath);
|
|
141
|
-
unlinkSync(tempPath);
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
try {
|
|
146
|
-
unlinkSync(tempPath);
|
|
147
|
-
}
|
|
148
|
-
catch { /* process crash leaves harmless temp */ }
|
|
149
|
-
if (err.code !== 'EEXIST')
|
|
150
|
-
throw err;
|
|
151
|
-
let incumbent;
|
|
152
|
-
try {
|
|
153
|
-
incumbent = readFileSync(lockPath, 'utf8');
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (!ownerIsAlive(incumbent))
|
|
159
|
-
reclaimDeadLock(lockPath, incumbent);
|
|
160
|
-
if (Date.now() >= deadline) {
|
|
161
|
-
throw new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.');
|
|
162
|
-
}
|
|
163
|
-
sleepSync(WORKTREE_LOCK_POLL_MS);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
return action();
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
// Token-checking release cannot unlock a replacement owner's lock.
|
|
171
|
-
try {
|
|
172
|
-
if (readFileSync(lockPath, 'utf8') === owner)
|
|
173
|
-
unlinkSync(lockPath);
|
|
174
|
-
}
|
|
175
|
-
catch { /* already reclaimed after a process-level failure */ }
|
|
176
|
-
}
|
|
83
|
+
const lockPath = `${lockRoot}/${createHash('sha256').update(commonDir).digest('hex')}`;
|
|
84
|
+
return withExclusiveDirectoryLock(lockPath, action, {
|
|
85
|
+
timeoutMs: WORKTREE_LOCK_WAIT_MS,
|
|
86
|
+
timeoutError: () => new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.'),
|
|
87
|
+
});
|
|
177
88
|
}
|
|
178
89
|
function registeredWorktree(repoRoot, path, branch) {
|
|
179
90
|
const list = gitSync(['worktree', 'list', '--porcelain'], repoRoot);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// `{id}` is the target node.
|
|
9
9
|
import { readdirSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
10
11
|
import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
|
|
11
12
|
import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
|
|
12
13
|
import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
@@ -29,6 +30,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
|
|
|
29
30
|
import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
|
|
30
31
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
31
32
|
import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
|
|
33
|
+
import { profileHome } from '../../../core/profiles/manifest.js';
|
|
32
34
|
import { fullName } from '../../../core/canvas/labels.js';
|
|
33
35
|
import { getCron } from '../../../core/canvas/crons.js';
|
|
34
36
|
import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
|
|
@@ -85,11 +87,31 @@ function parseCreateBody(body) {
|
|
|
85
87
|
}
|
|
86
88
|
return b;
|
|
87
89
|
}
|
|
90
|
+
/** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
|
|
91
|
+
* managed child inherits its spawner's cwd (which is what keeps a worktree
|
|
92
|
+
* node's whole subtree inside that worktree); else the node runs in its
|
|
93
|
+
* profile's home. The request's own `cwd` — the caller's directory — is only
|
|
94
|
+
* the last resort, for a profile that has no home (notably root).
|
|
95
|
+
*
|
|
96
|
+
* This is the profile→cwd direction: `cwd` selected the profile a moment ago,
|
|
97
|
+
* and the profile now supplies where the node runs. Collapsing every create
|
|
98
|
+
* under a profile onto one directory is also what keeps the warm pool to one
|
|
99
|
+
* spare per profile instead of one per directory anyone happened to spawn from. */
|
|
100
|
+
function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
101
|
+
if (req.pin_cwd !== undefined && req.pin_cwd !== '')
|
|
102
|
+
return resolve(req.pin_cwd);
|
|
103
|
+
if (parent != null) {
|
|
104
|
+
const spawner = getNode(parent);
|
|
105
|
+
if (spawner !== null)
|
|
106
|
+
return spawner.cwd;
|
|
107
|
+
}
|
|
108
|
+
return profileHome(profileId) ?? contextCwd;
|
|
109
|
+
}
|
|
88
110
|
/** Build the immediate spawn recipe. */
|
|
89
|
-
function buildRecipe(req, parent) {
|
|
111
|
+
function buildRecipe(req, parent, cwd) {
|
|
90
112
|
const recipe = {
|
|
91
113
|
kind: req.kind,
|
|
92
|
-
cwd
|
|
114
|
+
cwd,
|
|
93
115
|
prompt: req.prompt ?? '',
|
|
94
116
|
};
|
|
95
117
|
if (req.mode !== undefined)
|
|
@@ -122,7 +144,7 @@ function buildRecipe(req, parent) {
|
|
|
122
144
|
* forked conversation, a managed worktree, a caller-pinned node id, a spine
|
|
123
145
|
* parent — disqualifies it. `kind`, `mode`, `model`, `name`, `situational_context`
|
|
124
146
|
* and `output_schema` are all applied at claim time and so do not. */
|
|
125
|
-
function warmRequestFor(req) {
|
|
147
|
+
function warmRequestFor(req, cwd, profileId) {
|
|
126
148
|
if (!warmPoolEnabled())
|
|
127
149
|
return null;
|
|
128
150
|
if (req.prefer_warm !== true)
|
|
@@ -136,8 +158,8 @@ function warmRequestFor(req) {
|
|
|
136
158
|
return {
|
|
137
159
|
kind: req.kind,
|
|
138
160
|
mode: (req.mode ?? 'base'),
|
|
139
|
-
cwd
|
|
140
|
-
|
|
161
|
+
cwd,
|
|
162
|
+
profileId,
|
|
141
163
|
model: req.model ?? null,
|
|
142
164
|
situationalContext: req.situational_context ?? null,
|
|
143
165
|
name: req.name ?? null,
|
|
@@ -184,8 +206,9 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
184
206
|
// Resolve and validate the exact launch recipe before the warm pool can claim
|
|
185
207
|
// a spare (or the cold path can create a worktree/node). The registry belongs
|
|
186
208
|
// to this daemon, never to the possibly-remote CLI caller.
|
|
187
|
-
const
|
|
188
|
-
const profileId = await resolveProfileId(req.profile, parent ?? null,
|
|
209
|
+
const contextCwd = req.cwd ?? process.cwd();
|
|
210
|
+
const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
|
|
211
|
+
const cwd = resolveNodeCwd(req, parent, profileId, contextCwd);
|
|
189
212
|
const launch = buildLaunchSpec(req.kind, (req.mode ?? 'base'), {
|
|
190
213
|
lifecycle: req.root === true ? 'resident' : 'terminal',
|
|
191
214
|
hasManager: req.root !== true,
|
|
@@ -197,7 +220,7 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
197
220
|
// --- Warm path: hand out an already-booted spare instead of paying a full
|
|
198
221
|
// engine boot. A miss is not a failure — it falls through to the cold
|
|
199
222
|
// spawn below and mints the spare that serves the NEXT create. ---
|
|
200
|
-
const warm = warmRequestFor(req);
|
|
223
|
+
const warm = warmRequestFor(req, cwd, profileId);
|
|
201
224
|
if (warm !== null) {
|
|
202
225
|
const claimed = await claimWarmNode(warm);
|
|
203
226
|
refillWarmPool(warm);
|
|
@@ -207,7 +230,7 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
207
230
|
return { status: 201, body: detailOf(claimed) };
|
|
208
231
|
}
|
|
209
232
|
}
|
|
210
|
-
const recipe = buildRecipe(req, parent);
|
|
233
|
+
const recipe = buildRecipe(req, parent, cwd);
|
|
211
234
|
recipe.readiness = req.root === true ? 'await' : 'launch';
|
|
212
235
|
const result = await spawnChild(recipe);
|
|
213
236
|
if (schema !== null)
|
|
@@ -2,45 +2,23 @@
|
|
|
2
2
|
// input and derive authorship; review/comment state remains in the review
|
|
3
3
|
// services and comment delivery remains a post-commit best effort.
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { ApiError } from '../../../api/index.js';
|
|
6
5
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
7
6
|
import { isSafeNodeId } from '../../../core/canvas/paths.js';
|
|
8
7
|
import { createComment, deleteComment, editComment, getComment, listCommentEvents, listComments, listReviewEvents, reopenComment, resolveComment, } from '../../../core/review/comments.js';
|
|
9
8
|
import { withCanvasWrite } from '../../../core/canvas/db.js';
|
|
10
9
|
import { captureAnchorText, requireCurrentDocumentLocked } from '../../../core/review/document.js';
|
|
11
10
|
import { applyRangeBatch } from '../../../core/review/remap.js';
|
|
12
|
-
import {
|
|
11
|
+
import { getReviewByCompanion, requireVisibleReview } from '../../../core/review/store.js';
|
|
13
12
|
import { ReviewOperationError } from '../../../core/review/types.js';
|
|
14
13
|
import { notifyComment } from '../../review/comment-notify.js';
|
|
15
14
|
import { toReviewCommentDTO, toReviewCommentEventDTO } from '../map.js';
|
|
15
|
+
import { invalidRequest as rejectRequest, isRecord, objectBody, requireParam, requiredString, } from './validate.js';
|
|
16
16
|
const COMMENT_ID = /^[a-f0-9]{32}$/;
|
|
17
17
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
18
18
|
const COMMENT_STATUSES = new Set(['open', 'resolved', 'deleted', 'all']);
|
|
19
19
|
const DELIVER_MODES = new Set(['wake', 'quiet']);
|
|
20
20
|
function invalidRequest(violations) {
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
function isRecord(value) {
|
|
24
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
-
}
|
|
26
|
-
function objectBody(body, allowed, violations) {
|
|
27
|
-
if (!isRecord(body)) {
|
|
28
|
-
violations.push({ field: 'body', problem: 'must be an object', received: body });
|
|
29
|
-
return {};
|
|
30
|
-
}
|
|
31
|
-
for (const key of Object.keys(body)) {
|
|
32
|
-
if (!allowed.includes(key))
|
|
33
|
-
violations.push({ field: key, problem: 'is not allowed', received: body[key] });
|
|
34
|
-
}
|
|
35
|
-
return body;
|
|
36
|
-
}
|
|
37
|
-
function requiredString(body, field, violations) {
|
|
38
|
-
const value = body[field];
|
|
39
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
40
|
-
violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
return value;
|
|
21
|
+
rejectRequest('review comment request', violations);
|
|
44
22
|
}
|
|
45
23
|
function optionalSha256(body, field, violations) {
|
|
46
24
|
const value = body[field];
|
|
@@ -135,27 +113,10 @@ function anchorFrom(value, field, violations) {
|
|
|
135
113
|
};
|
|
136
114
|
}
|
|
137
115
|
function requireReviewId(ctx, violations) {
|
|
138
|
-
|
|
139
|
-
if (reviewId === undefined || !isSafeNodeId(reviewId)) {
|
|
140
|
-
violations.push({ field: 'review_id', problem: 'must be a safe review id', ...(reviewId === undefined ? {} : { received: reviewId }) });
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
|
-
return reviewId;
|
|
116
|
+
return requireParam(ctx, 'review_id', 'must be a safe review id', isSafeNodeId, violations);
|
|
144
117
|
}
|
|
145
118
|
function requireCommentId(ctx, violations) {
|
|
146
|
-
|
|
147
|
-
if (commentId === undefined || !COMMENT_ID.test(commentId)) {
|
|
148
|
-
violations.push({ field: 'comment_id', problem: 'must be a 32-character lowercase hexadecimal id', ...(commentId === undefined ? {} : { received: commentId }) });
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
return commentId;
|
|
152
|
-
}
|
|
153
|
-
function requireVisibleReview(reviewId) {
|
|
154
|
-
const review = getReview(reviewId);
|
|
155
|
-
if (review === null || review.state === 'binding') {
|
|
156
|
-
throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
|
|
157
|
-
}
|
|
158
|
-
return review;
|
|
119
|
+
return requireParam(ctx, 'comment_id', 'must be a 32-character lowercase hexadecimal id', (v) => COMMENT_ID.test(v), violations);
|
|
159
120
|
}
|
|
160
121
|
function requireOpenReview(reviewId) {
|
|
161
122
|
const review = requireVisibleReview(reviewId);
|