viewable-one 0.0.0-stage → 0.12.1
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 +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Runner registration and presence, moved out of runner.mjs on 2026-10-03
|
|
2
|
+
// (ticket e8) when `register` became one call per process per beat. The
|
|
3
|
+
// watch loop in runner.mjs is the caller; runner.mjs re-exports
|
|
4
|
+
// `registerAndFindProject` for the tests that reach it there.
|
|
5
|
+
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
|
|
8
|
+
import * as runnerApiModule from "./runnerApi.mjs";
|
|
9
|
+
import { addFolder, foldersWithoutGit, isScratchFolder, markFolderRestore, readRegistry, withRegistryLock } from "./registry.mjs";
|
|
10
|
+
import { platformMark } from "./platform.mjs";
|
|
11
|
+
|
|
12
|
+
/** `register` every folder this machine knows about, plus `root` itself as
|
|
13
|
+
* a fallback for a single-folder invocation (PM_BOARD_ROOT) that was never
|
|
14
|
+
* added to `~/.glytch/folders.json`. A machine running several `watch()`
|
|
15
|
+
* loops (multiwatch.mjs, one per folder) shares one call per beat through
|
|
16
|
+
* `settings.registrar` (ticket e8). Returns `{ok, projects,
|
|
17
|
+
* myProjectId}` - `myProjectId` is `root`'s own id in the response, or
|
|
18
|
+
* undefined when registration failed. */
|
|
19
|
+
/**
|
|
20
|
+
* WHAT THIS RUNNER SAYS ABOUT ITSELF, in one place (2026-09-13).
|
|
21
|
+
*
|
|
22
|
+
* `register` used to add `planApproval: true` and the heartbeat did not - and
|
|
23
|
+
* the heartbeat REPLACES `runner_projects.presence`, not merges into it. So
|
|
24
|
+
* the flag lived for one beat interval after each register and was then wiped,
|
|
25
|
+
* which the app reads as a runner that cannot do read-only planning. Every
|
|
26
|
+
* plan-mode run was refused with "Plan approval requires Claude Code or Codex,
|
|
27
|
+
* and an updated runner that supports read-only planning" on a runner that
|
|
28
|
+
* supports it perfectly well - found by driving it, because the server row
|
|
29
|
+
* DOES carry the flag for the second after a register and looks right to
|
|
30
|
+
* anyone who queries it at the wrong moment.
|
|
31
|
+
*
|
|
32
|
+
* Two writers of one body is how that happened, so there is one now.
|
|
33
|
+
*/
|
|
34
|
+
export const presenceBody = (settings) => {
|
|
35
|
+
const { platform: _platform, ...rest } = settings.markExtra?.() ?? {};
|
|
36
|
+
return { ...rest, planApproval: true, computerAppsLook: true, ...(settings.noGit?.length ? { noGit: settings.noGit } : {}) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function registerAndFindProject(root, settings, { readd = false } = {}) {
|
|
40
|
+
if (!readd) return registerProject(root, settings);
|
|
41
|
+
const env = settings.env ?? process.env;
|
|
42
|
+
return withRegistryLock(async () => {
|
|
43
|
+
await markFolderRestore(root, env);
|
|
44
|
+
const out = await registerProject(root, settings, { readd: true });
|
|
45
|
+
// Publish the local entry before releasing the restore lock. syncRegistry
|
|
46
|
+
// in init remains idempotent, including when Remove won the lock first.
|
|
47
|
+
if (out.ok) await addFolder(root, env);
|
|
48
|
+
return out;
|
|
49
|
+
}, env);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function registerProject(root, settings, { readd = false } = {}) {
|
|
53
|
+
const runnerApi = settings.runnerApi ?? runnerApiModule;
|
|
54
|
+
const env = settings.env ?? process.env;
|
|
55
|
+
const folders = await readRegistry(env);
|
|
56
|
+
// Init only owns this folder; a routine daemon register owns the full list.
|
|
57
|
+
const paths = readd ? [root] : folders.length > 0 ? folders : [root];
|
|
58
|
+
const list = paths.filter((path) => !isScratchFolder(path, env));
|
|
59
|
+
if (list.length === 0) return { ok: false, error: `${root} is a temporary folder, and those are never registered. Run the runner in a project folder.` };
|
|
60
|
+
// Only init opts in. Capture the removal it intends to undo before any
|
|
61
|
+
// register work; later folders in a slow routine register acquire no intent.
|
|
62
|
+
let readdForgottenAt;
|
|
63
|
+
let runnerId = settings.runnerId ?? "runner";
|
|
64
|
+
const version = readd ? (settings.markExtra?.() ?? {}).version : undefined;
|
|
65
|
+
const requestOptions = { env, fetchImpl: settings.fetchImpl,
|
|
66
|
+
...(version ? { headers: { "User-Agent": `glytch/${version}` } } : {}),
|
|
67
|
+
};
|
|
68
|
+
if (readd) {
|
|
69
|
+
const before = await runnerApi.presence({ paths: [root] }, requestOptions);
|
|
70
|
+
if (!before.ok) return { ...before, readdRequired: false };
|
|
71
|
+
const existing = (before.projects ?? []).find(p => p.path === root && p.host === (settings.host ?? "local"));
|
|
72
|
+
readdForgottenAt = existing?.forgotten_at;
|
|
73
|
+
// Preserve the watcher identity so its eventual departure still matches.
|
|
74
|
+
runnerId = existing?.runner_id || runnerId;
|
|
75
|
+
}
|
|
76
|
+
settings.noGit = foldersWithoutGit(list);
|
|
77
|
+
const send = () => runnerApi.register(
|
|
78
|
+
{
|
|
79
|
+
runnerId,
|
|
80
|
+
host: settings.host ?? "local",
|
|
81
|
+
platform: (settings.markExtra?.() ?? {}).platform ?? platformMark().platform,
|
|
82
|
+
presence: presenceBody(settings),
|
|
83
|
+
projects: list.map((path) => ({ path, name: basename(path),
|
|
84
|
+
...(path === root && readdForgottenAt ? { readdForgottenAt } : {}),
|
|
85
|
+
})),
|
|
86
|
+
},
|
|
87
|
+
requestOptions,
|
|
88
|
+
);
|
|
89
|
+
// ONE REGISTER PER PROCESS PER BEAT (ticket e8). multiwatch hands every
|
|
90
|
+
// folder the same `registrar`, so the folders that beat inside one window
|
|
91
|
+
// share one call for the same folder list instead of each sending it.
|
|
92
|
+
const out = settings.registrar ? await settings.registrar.register(JSON.stringify(list), send) : await send();
|
|
93
|
+
if (!out.ok) return { ok: false, error: out.error, ...(readd ? { readdRequired: Boolean(readdForgottenAt) } : {}), ...(out.transient ? { transient: true } : {}), ...(Number.isFinite(out.retryAfterMs) ? { retryAfterMs: out.retryAfterMs } : {}) };
|
|
94
|
+
const mine = (out.projects ?? []).find((p) => p.path === root);
|
|
95
|
+
return { ok: true, projects: out.projects ?? [], myProjectId: mine?.id };
|
|
96
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// THE RUNNER'S SETTINGS, READ OFF THE ENVIRONMENT (moved out of runner.mjs
|
|
2
|
+
// on 2026-09-27, verbatim). The PM_SPAWN_* switches and their defaults; the
|
|
3
|
+
// runner and every test that builds one import `settingsFromEnv` from
|
|
4
|
+
// runner.mjs, which re-exports it from here.
|
|
5
|
+
|
|
6
|
+
import { DEFAULTS } from "./child.mjs";
|
|
7
|
+
|
|
8
|
+
const num = (value, fallback) => {
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function settingsFromEnv(env = process.env) {
|
|
14
|
+
return {
|
|
15
|
+
enabled: env.PM_SPAWN_RUNNER === "on",
|
|
16
|
+
env,
|
|
17
|
+
claudeBin: env.PM_CLAUDE_BIN?.trim() || undefined,
|
|
18
|
+
claudeArgs: parseArgs(env.PM_CLAUDE_ARGS),
|
|
19
|
+
maxConcurrent: num(env.PM_SPAWN_MAX_CONCURRENT, DEFAULTS.maxConcurrent),
|
|
20
|
+
pollMs: num(env.PM_SPAWN_POLL_MS, DEFAULTS.pollMs),
|
|
21
|
+
beatMs: num(env.PM_SPAWN_BEAT_MS, DEFAULTS.beatMs),
|
|
22
|
+
stallMs: num(env.PM_SPAWN_STALL_MS, DEFAULTS.stallMs),
|
|
23
|
+
// How often a live streaming child's answers-and-messages poll asks
|
|
24
|
+
// collect_session while disconnected; joined polls use reply hints and a minute safety read.
|
|
25
|
+
rowsPollMs: num(env.PM_ROWS_POLL_MS, 5000),
|
|
26
|
+
// How often the live child log is flushed to `run_events` (item 3 of the
|
|
27
|
+
// Move 2 contract's runner section). Default 3000ms.
|
|
28
|
+
logFlushMs: num(env.PM_LOG_FLUSH_MS, 3000),
|
|
29
|
+
depth: Number(env.PM_SPAWN_DEPTH ?? 0) || 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(raw) {
|
|
34
|
+
if (typeof raw !== "string" || raw.trim() === "") return undefined;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (Array.isArray(parsed) && parsed.every((a) => typeof a === "string")) return parsed;
|
|
38
|
+
} catch { /* fall through */ }
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Durable terminal receipts. Execution never repeats to repair a failed
|
|
2
|
+
* status write; watch/startup replay the same receipt until acknowledged. */
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, open, readdir, readFile, rename, unlink } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { glytchHome } from "./registry.mjs";
|
|
7
|
+
import * as defaultApi from "./runnerApi.mjs";
|
|
8
|
+
|
|
9
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
10
|
+
export const completionDirectory = (project, settings) => join(glytchHome(settings.env), "run-completions", digest(`${project.id}\0${project.path}`));
|
|
11
|
+
const memoryReceipts = new Map();
|
|
12
|
+
const terminal = new Set(["done", "error", "stopped"]);
|
|
13
|
+
|
|
14
|
+
export async function prepareCompletionStore(project, settings) {
|
|
15
|
+
const dir = completionDirectory(project, settings);
|
|
16
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
17
|
+
// Prove writability before starting work whose receipt we must retain.
|
|
18
|
+
const probe = join(dir, `.probe-${randomUUID()}`);
|
|
19
|
+
const handle = await open(probe, "wx", 0o600);
|
|
20
|
+
await handle.close();
|
|
21
|
+
await unlink(probe);
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeReceipt(project, runId, patch, settings) {
|
|
26
|
+
const dir = await prepareCompletionStore(project, settings);
|
|
27
|
+
const path = join(dir, `${digest(runId)}.json`);
|
|
28
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
29
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
30
|
+
try {
|
|
31
|
+
await handle.writeFile(JSON.stringify({ projectId: project.id, runId, patch }));
|
|
32
|
+
await handle.sync();
|
|
33
|
+
} finally { await handle.close(); }
|
|
34
|
+
try { await rename(temporary, path); }
|
|
35
|
+
finally { await unlink(temporary).catch(() => {}); }
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const replaying = new Map();
|
|
40
|
+
async function deliver(path, record, settings, log, attempts) {
|
|
41
|
+
if (replaying.has(path)) return replaying.get(path);
|
|
42
|
+
const task = (async () => {
|
|
43
|
+
const api = settings.runnerApi ?? defaultApi;
|
|
44
|
+
const options = { env: settings.env, fetchImpl: settings.fetchImpl };
|
|
45
|
+
let last;
|
|
46
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
47
|
+
try { last = await api.update({ runId: record.runId, patch: record.patch }, options); }
|
|
48
|
+
catch (error) { last = { ok: false, error: error.message }; }
|
|
49
|
+
if (last?.ok) {
|
|
50
|
+
await unlink(path).catch(error => { if (error.code !== "ENOENT") log(`[${record.runId}] completion acknowledged; local cleanup will retry: ${error.message}`); });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (attempt + 1 < attempts) await new Promise(resolve => setTimeout(resolve, (settings.completionRetryMs ?? 250) * (attempt + 1)));
|
|
54
|
+
}
|
|
55
|
+
log(`[${record.runId}] completion retained for retry: ${last?.error ?? "update was not acknowledged"}`);
|
|
56
|
+
return false;
|
|
57
|
+
})();
|
|
58
|
+
replaying.set(path, task);
|
|
59
|
+
try { return await task; } finally { replaying.delete(path); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function settleRun(project, runId, patch, settings, log = () => {}) {
|
|
63
|
+
if (!terminal.has(patch.status)) throw new Error("A terminal receipt requires a terminal status.");
|
|
64
|
+
const record = { projectId: project.id, runId, patch };
|
|
65
|
+
const memoryKey = join(completionDirectory(project, settings), digest(runId));
|
|
66
|
+
memoryReceipts.set(memoryKey, record);
|
|
67
|
+
let path;
|
|
68
|
+
try { path = await writeReceipt(project, runId, patch, settings); }
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Never replace a completed result with a generic execution error when
|
|
71
|
+
// local storage fails. Try the server while retaining the original patch.
|
|
72
|
+
const api = settings.runnerApi ?? defaultApi;
|
|
73
|
+
const result = await api.update({ runId, patch }, { env: settings.env, fetchImpl: settings.fetchImpl }).catch(() => null);
|
|
74
|
+
if (result?.ok) {
|
|
75
|
+
memoryReceipts.delete(memoryKey);
|
|
76
|
+
return { id: runId, state: patch.status === "done" ? "done" : "failed", patch };
|
|
77
|
+
}
|
|
78
|
+
log(`[${runId}] completion retained in memory; local storage is unavailable: ${error.message}`);
|
|
79
|
+
return { id: runId, state: "pending-sync", patch };
|
|
80
|
+
|
|
81
|
+
}
|
|
82
|
+
memoryReceipts.delete(memoryKey); // The atomically written disk receipt now owns retries.
|
|
83
|
+
const synced = await deliver(path, record, settings, log, 3);
|
|
84
|
+
return { id: runId, state: synced ? (patch.status === "done" ? "done" : "failed") : "pending-sync", patch };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function flushCompletions(project, settings, log = () => {}) {
|
|
88
|
+
const dir = completionDirectory(project, settings);
|
|
89
|
+
let complete = true;
|
|
90
|
+
for (const [key, record] of [...memoryReceipts]) {
|
|
91
|
+
if (key !== join(dir, digest(record.runId))) continue;
|
|
92
|
+
const result = await settleRun(project, record.runId, record.patch, settings, log);
|
|
93
|
+
if (result.state === "pending-sync") complete = false;
|
|
94
|
+
}
|
|
95
|
+
let names;
|
|
96
|
+
try { names = await readdir(dir); }
|
|
97
|
+
catch (error) { if (error.code === "ENOENT") return complete; throw error; }
|
|
98
|
+
for (const name of names.filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
|
|
99
|
+
const path = join(dir, name);
|
|
100
|
+
try {
|
|
101
|
+
const record = JSON.parse(await readFile(path, "utf8"));
|
|
102
|
+
if (record.projectId !== project.id || typeof record.runId !== "string" || `${digest(record.runId)}.json` !== name || !terminal.has(record.patch?.status)) throw new Error("Invalid saved completion receipt");
|
|
103
|
+
if (!(await deliver(path, record, settings, log, 1))) complete = false;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === "ENOENT") continue;
|
|
106
|
+
complete = false;
|
|
107
|
+
log(`completion replay deferred: ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return complete;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Did this run end because `--resume` found no conversation, having done
|
|
114
|
+
* nothing at all? Unchanged pure function from the file-based runner. */
|
|
115
|
+
export function isMissingConversation(result) {
|
|
116
|
+
const envelope = result?.envelope;
|
|
117
|
+
if (!envelope || envelope.is_error !== true) return false;
|
|
118
|
+
if (Number(envelope.num_turns ?? 0) !== 0) return false;
|
|
119
|
+
if (Number(envelope.total_cost_usd ?? 0) !== 0) return false;
|
|
120
|
+
const errors = Array.isArray(envelope.errors) ? envelope.errors : [];
|
|
121
|
+
return errors.some((e) => typeof e === "string" && e.includes("No conversation found"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function settleMeasuredRun(project, id, patch, settings, log, timing, receipt) {
|
|
125
|
+
const measurement = timing.snapshot();
|
|
126
|
+
try { log(`runner.performance ${JSON.stringify(measurement)}`); } catch { /* Diagnostics never prevent settlement. */ }
|
|
127
|
+
const started = performance.now();
|
|
128
|
+
const result = await settleRun(project, id, { ...patch, sidecar: { ...(patch.sidecar ?? receipt.sidecar), performance: measurement } }, settings, log);
|
|
129
|
+
try { log(`runner.delivery ${JSON.stringify({ duration_ms: Math.max(0, Math.round(performance.now() - started)), outcome: result.state === "pending-sync" ? "pending" : "acknowledged" })}`); } catch { /* Optional diagnostics. */ }
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function settleFailedRun(project, id, error, settings, log, timing, receipt) {
|
|
134
|
+
return settleMeasuredRun(project, id, {
|
|
135
|
+
status: "error", result: `ERROR\n\nThe runner could not complete this run: ${error.message}\n`,
|
|
136
|
+
finished_at: new Date().toISOString(),
|
|
137
|
+
}, settings, log, timing, receipt);
|
|
138
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Select a run's working directory and derive resume from the tree that
|
|
2
|
+
// survived. Called after claim and admission; never spawns a child.
|
|
3
|
+
//
|
|
4
|
+
// ROWS VERSION (brief 30 Move 2/2b, 2026-09-11). The file-based runner used
|
|
5
|
+
// to wait for `startSessionOnBoard`'s receipt before this ran, and a refusal
|
|
6
|
+
// here wrote a comment, filed an `endSession` op and a `.result.txt`
|
|
7
|
+
// receipt. There is no more start-and-wait: `claim` IS the start (the run
|
|
8
|
+
// row moves to `status:'running'` the moment it is claimed), so a refusal
|
|
9
|
+
// here has nothing to "close" that was not already open - it writes a
|
|
10
|
+
// comment (when the run has an idea at all) and hands the caller a result
|
|
11
|
+
// whose `patch` flips the row straight to `status:'error'`.
|
|
12
|
+
import { makeWorktree, reuseWorktree, resolveContainedCwd } from "./worktree.mjs";
|
|
13
|
+
import { commentOnAnchor } from "./boardside.mjs";
|
|
14
|
+
import { titleFor } from "./spawn.mjs";
|
|
15
|
+
|
|
16
|
+
const defaultServices = { makeWorktree, reuseWorktree, commentOnAnchor, titleFor };
|
|
17
|
+
|
|
18
|
+
/** The preamble's "where you work" sentence for a run that is NOT isolated
|
|
19
|
+
* (`preambleFor(agent, { workplace })`), the same for every engine.
|
|
20
|
+
*
|
|
21
|
+
* QA 2026-10-01 (ticket e2): a folder with no git repo runs IN PLACE
|
|
22
|
+
* (`makeWorktree` returns `isolated: false`), yet every session was told "You
|
|
23
|
+
* are in your own git worktree: commit your work on this branch". Claude Code
|
|
24
|
+
* wrote the file anyway; Codex asked "Where is the git worktree for this
|
|
25
|
+
* task?" and the run blocked until the person answered. An in-place run is
|
|
26
|
+
* told so, and told not to ask - there is nothing to find. */
|
|
27
|
+
export const IN_PLACE_WORKPLACE =
|
|
28
|
+
"You are working directly in the person's folder, not in a git worktree of " +
|
|
29
|
+
"your own: this folder has no git repo, or isolation was turned off for this " +
|
|
30
|
+
"prompt. Make your changes in place, in the current directory. There is no " +
|
|
31
|
+
"worktree or branch to find, so do not ask where one is, and do not commit, " +
|
|
32
|
+
"merge or switch branches unless the prompt asks you to.\n\n";
|
|
33
|
+
|
|
34
|
+
/** `context`: `{root, id, request, isResume, session, from, log,
|
|
35
|
+
* otherRunningCount, updateSidecarEarly, isChat, chatCwd}` - `root` is the
|
|
36
|
+
* claimed row's project path, `id` its run id. `otherRunningCount()` and
|
|
37
|
+
* `updateSidecarEarly(patch)` are injected rather than imported so this
|
|
38
|
+
* module stays free of `runnerApi` vocabulary, the way it was free of
|
|
39
|
+
* `spawn.mjs`'s file vocabulary before Move 2. `isChat`/`chatCwd` come from
|
|
40
|
+
* `runner.mjs`'s own chat-turn handling (cwd resolution, minting, the
|
|
41
|
+
* per-room lock) - this module's only job for one is to build `made` around
|
|
42
|
+
* the RESOLVED absolute `chatCwd` rather than a worktree (chat turn
|
|
43
|
+
* difference 2: no worktree is minted per message). Returns `{ok:true,
|
|
44
|
+
* made, resumeTreeGone, resumeSessionId}` or `{ok:false, patch}` - the
|
|
45
|
+
* caller writes `patch` through `runnerApi.update`. */
|
|
46
|
+
export async function prepareRequestWorkspace(context, services = defaultServices) {
|
|
47
|
+
const { root, id, request, isResume, session, from, log, otherRunningCount, updateSidecarEarly, isChat, chatCwd, claimedAt, ownTreePath } = context;
|
|
48
|
+
const { makeWorktree, reuseWorktree, commentOnAnchor: comment, titleFor: title } = services;
|
|
49
|
+
|
|
50
|
+
// A NAMED cwd STAYS IN THE LINKED FOLDER (#324). A chat turn's was
|
|
51
|
+
// resolved and fenced by the caller (`chatCwd`); every other request's is
|
|
52
|
+
// fenced here, before anything is made or spawned, the same way.
|
|
53
|
+
let namedCwd = null;
|
|
54
|
+
if (!isChat && request.cwd !== undefined) {
|
|
55
|
+
const resolved = resolveContainedCwd(root, request.cwd);
|
|
56
|
+
if (!resolved.ok) {
|
|
57
|
+
const detail =
|
|
58
|
+
`NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
|
|
59
|
+
`This request names ${String(request.cwd ?? "").trim()} as the directory to run in, and ${resolved.reason}`;
|
|
60
|
+
log(`[${id}] refused: the request's cwd ${String(request.cwd ?? "").trim()} is outside ${root}`);
|
|
61
|
+
return { ok: false, patch: { status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() } };
|
|
62
|
+
}
|
|
63
|
+
namedCwd = resolved.path;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (request.planMode === "draft") {
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
made: { cwd: namedCwd ?? root, isolated: false, detail: "Read-only planning in the source checkout. No worktree created." },
|
|
70
|
+
resumeTreeGone: false,
|
|
71
|
+
resumeSessionId: "",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// THE RECORDED TREE, for a resume and for a new session that names it (a
|
|
76
|
+
// Codex or opencode note after a done session, review 2026-10-04 D7): the
|
|
77
|
+
// new session works on the kept branch. A tree that is gone mints a new one.
|
|
78
|
+
// ONLY THIS IDEA'S OWN TREE (round 3 item 67): `ownTreePath` is the tree
|
|
79
|
+
// a run of the request's idea made that resolves to the named path, in
|
|
80
|
+
// the tree's own spelling (runner.mjs, round 4 item 70); null when the
|
|
81
|
+
// request names another idea's tree or a path no row of this idea made.
|
|
82
|
+
const wantedRecordedTree = !isChat && !request.cwd && request.useWorktree !== false && Boolean(request.worktreePath);
|
|
83
|
+
const isOwnTree = wantedRecordedTree && typeof ownTreePath === "string" && ownTreePath !== "";
|
|
84
|
+
if (wantedRecordedTree && !isOwnTree) log(`[${id}] the named worktree ${request.worktreePath} is not this idea's own; not reused`);
|
|
85
|
+
const reusedTree = isOwnTree ? await reuseWorktree(root, ownTreePath, log) : null;
|
|
86
|
+
if (request.recovery === true && wantedRecordedTree && !reusedTree) {
|
|
87
|
+
return { ok: false, patch: { status: "error", result: "ERROR\n\nThe separate copy is gone.\n", finished_at: new Date().toISOString(), sidecar: { recoveryFailed: true } } };
|
|
88
|
+
}
|
|
89
|
+
if (wantedRecordedTree && !isResume && !reusedTree) log(`[${id}] the kept worktree ${request.worktreePath} is gone; this session gets a new one`);
|
|
90
|
+
const made = isChat
|
|
91
|
+
? {
|
|
92
|
+
// THE RESOLVED ABSOLUTE PATH, never the field as written. The
|
|
93
|
+
// sidecar records this under `worktreePath` and the app sends it
|
|
94
|
+
// straight back as the next turn's `cwd`, so a relative first turn
|
|
95
|
+
// becomes an absolute second one and the conversation stays in one
|
|
96
|
+
// directory.
|
|
97
|
+
cwd: chatCwd,
|
|
98
|
+
isolated: false,
|
|
99
|
+
detail: `Chat turn: ran in ${chatCwd}, the agent's own chat worktree. No worktree was made for this message and none was reused.`,
|
|
100
|
+
}
|
|
101
|
+
: reusedTree
|
|
102
|
+
? reusedTree
|
|
103
|
+
: namedCwd
|
|
104
|
+
? { cwd: namedCwd, isolated: false, detail: `Ran in the cwd the request named, ${namedCwd}.` }
|
|
105
|
+
: request.useWorktree === false
|
|
106
|
+
? { cwd: root, isolated: false, detail: `Ran in the linked folder per idea setting (worktree off).` }
|
|
107
|
+
: await makeWorktree(root, id, log, title(request));
|
|
108
|
+
|
|
109
|
+
const wantedIsolation = !isChat && !request.cwd && request.useWorktree !== false;
|
|
110
|
+
if (wantedIsolation && !made.isolated) {
|
|
111
|
+
const others = typeof otherRunningCount === "function" ? await otherRunningCount() : 0;
|
|
112
|
+
if (others > 0) {
|
|
113
|
+
const detail =
|
|
114
|
+
`NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
|
|
115
|
+
`This session could not get its own worktree, and ${others} other ` +
|
|
116
|
+
`session${others === 1 ? " is" : "s are"} running. Falling back to the linked ` +
|
|
117
|
+
`folder would have put two agents in one working tree, editing the same files ` +
|
|
118
|
+
`with no branch between them.\n\n${made.detail}\n\n` +
|
|
119
|
+
`File the request again once they are done, or fix what stopped the worktree.`;
|
|
120
|
+
const frameId = typeof from === "string" ? from : undefined;
|
|
121
|
+
if (frameId) {
|
|
122
|
+
await comment(session, frameId, `Refused before spawning: no worktree, and another session is running\nNothing was spawned and nothing was billed. ${made.detail}`);
|
|
123
|
+
}
|
|
124
|
+
log(`[${id}] refused: no worktree while ${others} other session(s) run`);
|
|
125
|
+
return { ok: false, patch: { status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() } };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// PREP TIME, NAMED (2026-09-21, the claim-to-spawn gap). `claimedAt` is
|
|
129
|
+
// `runner.mjs`'s own `Date.now()` from the moment `runRequest` started,
|
|
130
|
+
// right after `claimOne` returned - so this is everything between the
|
|
131
|
+
// claim and the worktree being ready: the hosted-token check, the repo
|
|
132
|
+
// lock, `git worktree add`, the node_modules symlink. One number, so the
|
|
133
|
+
// next silent multi-second gap shows up as a big one rather than nothing.
|
|
134
|
+
const prepSecs = Number.isFinite(claimedAt) ? ((Date.now() - claimedAt) / 1000).toFixed(1) : null;
|
|
135
|
+
log(`[${id}] ${made.isolated ? `worktree ${made.branch}` : "NO worktree"}; spawning${prepSecs !== null ? ` (prep ${prepSecs}s)` : ""}`);
|
|
136
|
+
|
|
137
|
+
// THE WORKTREE, VISIBLE WHILE THE SESSION IS STILL RUNNING (unchanged
|
|
138
|
+
// reasoning from the file-based runner, 2026-09-08 - see git history).
|
|
139
|
+
// Only for a tree that really is one: a run that fell back to the linked
|
|
140
|
+
// checkout has no worktree of its own, and recording the checkout under
|
|
141
|
+
// `sidecar.worktreePath` would put Merge and Delete over the linked
|
|
142
|
+
// folder itself.
|
|
143
|
+
if (made.isolated && made.path && typeof updateSidecarEarly === "function") {
|
|
144
|
+
try {
|
|
145
|
+
await updateSidecarEarly({ worktreePath: made.path, fromId: from });
|
|
146
|
+
log(`[${id}] early sidecar written: worktree=${made.path}`);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
log(`[${id}] early sidecar write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A CHAT TURN NEVER ASKED FOR A RECORDED TREE EITHER. Its later requests
|
|
153
|
+
// DO carry `worktreePath` (the sidecar records the cwd under that name so
|
|
154
|
+
// the app can hand it straight back), but nothing above reused a tree for
|
|
155
|
+
// it - without `!isChat` in wantedRecordedTree every message after the first would read as
|
|
156
|
+
// "the recorded tree is gone" and the conversation would restart on every
|
|
157
|
+
// turn. Its directory was checked at the top of `runRequest`, which IS the
|
|
158
|
+
// "can this conversation be reached" question this line exists to ask.
|
|
159
|
+
const resumeTreeGone = isResume && wantedRecordedTree && !reusedTree;
|
|
160
|
+
const resumeSessionId = isResume && !resumeTreeGone ? String(request.resumeSessionId).trim() : "";
|
|
161
|
+
return { ok: true, made, resumeTreeGone, resumeSessionId };
|
|
162
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// THE DETACHED DEV SERVERS, stopped from ROWS.
|
|
2
|
+
//
|
|
3
|
+
// A browser-frame session starts its dev server detached so the frame on the
|
|
4
|
+
// board outlives the session (2026-09-06, ruling A), and something has to
|
|
5
|
+
// stop it later. Until brief 30 that was lib/sweep.mjs reading a
|
|
6
|
+
// `<id>.server` file the runner mirrored into `.glytch/inbox/spawn/`. Brief 30
|
|
7
|
+
// moved the record onto the run row's own `server` column
|
|
8
|
+
// (runnerCompletion.mjs) and nothing wrote the file any more - so the sweep
|
|
9
|
+
// found nothing, every day, and every detached server ran until the machine
|
|
10
|
+
// rebooted. This module is the rows half that brief left owed
|
|
11
|
+
// ("the sweep, once it moves onto rows", runner.mjs's relaunch comment).
|
|
12
|
+
//
|
|
13
|
+
// Two cases, the same two the file version had, and they are different
|
|
14
|
+
// questions:
|
|
15
|
+
//
|
|
16
|
+
// - the run FINISHED past the age: its frame is two weeks of history, so
|
|
17
|
+
// whatever it left listening is stopped with it;
|
|
18
|
+
// - the WORKTREE is gone but the record survives - the tree was merged or
|
|
19
|
+
// deleted, or a one-shot run cleaned it up - so the server is serving a
|
|
20
|
+
// directory that no longer exists. Stopped however young the run is.
|
|
21
|
+
//
|
|
22
|
+
// A run still running is never touched, whatever its age. The record is never
|
|
23
|
+
// trusted on its own: stopRecordedServer refuses a pid whose process does not
|
|
24
|
+
// match what the record describes, so a reused pid is logged and left alone.
|
|
25
|
+
// Either way the row's `server` is cleared afterwards - it stopped, or the
|
|
26
|
+
// process it names is not the one it describes any more, and a record kept in
|
|
27
|
+
// the second case would be re-checked every day for ever.
|
|
28
|
+
|
|
29
|
+
import { access } from "node:fs/promises";
|
|
30
|
+
import * as runnerApiModule from "./runnerApi.mjs";
|
|
31
|
+
import { stopRecordedServer } from "./devserver.mjs";
|
|
32
|
+
|
|
33
|
+
/** How long a finished run's server is left listening. The same 14 days
|
|
34
|
+
* lib/sweep.mjs gives a finished request's files. */
|
|
35
|
+
export const SERVER_SWEEP_AGE_MS = 14 * 86_400_000;
|
|
36
|
+
/** How often a watching folder looks - once a day, like the file sweep. */
|
|
37
|
+
export const SERVER_SWEEP_INTERVAL_MS = 86_400_000;
|
|
38
|
+
|
|
39
|
+
const pathExists = (path) => access(path).then(() => true, () => false);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stop every detached dev server this project's finished runs left behind
|
|
43
|
+
* and no longer need. Returns `{ stopped, left, kept }` as run ids.
|
|
44
|
+
*/
|
|
45
|
+
export async function sweepDetachedServers(project, settings, {
|
|
46
|
+
now = Date.now(),
|
|
47
|
+
ageMs = SERVER_SWEEP_AGE_MS,
|
|
48
|
+
log = () => {},
|
|
49
|
+
stopServer = stopRecordedServer,
|
|
50
|
+
exists = pathExists,
|
|
51
|
+
} = {}) {
|
|
52
|
+
const runnerApi = settings.runnerApi ?? runnerApiModule;
|
|
53
|
+
const rowsOpts = { env: settings.env, fetchImpl: settings.fetchImpl };
|
|
54
|
+
const stopped = [];
|
|
55
|
+
const left = [];
|
|
56
|
+
const kept = [];
|
|
57
|
+
const listed = await runnerApi.runs({ projectIds: [project.id] }, rowsOpts);
|
|
58
|
+
if (!listed.ok) throw new Error(`could not list runs: ${listed.error}`);
|
|
59
|
+
for (const run of listed.runs ?? []) {
|
|
60
|
+
const record = run.server;
|
|
61
|
+
if (!record || !Number.isFinite(Number(record.pid))) continue;
|
|
62
|
+
if (run.status === "running" || run.status === "pending") {
|
|
63
|
+
kept.push(run.id);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const finishedAt = typeof run.finished_at === "string" ? Date.parse(run.finished_at) : NaN;
|
|
67
|
+
const stale = Number.isFinite(finishedAt) && now - finishedAt > ageMs;
|
|
68
|
+
const treeGone = typeof record.worktreePath === "string" && record.worktreePath !== "" && !(await exists(record.worktreePath));
|
|
69
|
+
if (!stale && !treeGone) {
|
|
70
|
+
kept.push(run.id);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
let outcome;
|
|
74
|
+
try {
|
|
75
|
+
outcome = await stopServer(record);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
outcome = { stopped: false, reason: error instanceof Error ? error.message : String(error) };
|
|
78
|
+
}
|
|
79
|
+
const why = stale ? "run finished past the age" : "worktree gone";
|
|
80
|
+
log(
|
|
81
|
+
outcome.stopped
|
|
82
|
+
? `stopped the dev server for ${run.id} (pid ${record.pid}, port ${record.port ?? "unknown"}): ${why}, ${outcome.reason}`
|
|
83
|
+
: `left the dev server for ${run.id} (pid ${record.pid}) alone: ${outcome.reason}`,
|
|
84
|
+
);
|
|
85
|
+
(outcome.stopped ? stopped : left).push(run.id);
|
|
86
|
+
const cleared = await runnerApi.update({ runId: run.id, patch: { server: null } }, rowsOpts);
|
|
87
|
+
if (!cleared.ok) log(`[${run.id}] could not clear the dev server record: ${cleared.error}`);
|
|
88
|
+
}
|
|
89
|
+
return { stopped, left, kept };
|
|
90
|
+
}
|