vibe-coding-master 0.8.12 → 0.9.0

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.
Files changed (50) hide show
  1. package/README.md +7 -3
  2. package/dist/backend/cli/install-vcm-harness.js +57 -18
  3. package/dist/backend/gateway/gateway-service.js +5 -3
  4. package/dist/backend/runtime/terminal-submit.js +1 -0
  5. package/dist/backend/server.js +14 -4
  6. package/dist/backend/services/app-settings-service.js +3 -2
  7. package/dist/backend/services/auto-memory-service.js +6 -4
  8. package/dist/backend/services/claude-hook-service.js +6 -0
  9. package/dist/backend/services/claude-transcript-service.js +2 -13
  10. package/dist/backend/services/claude-version-check.js +46 -0
  11. package/dist/backend/services/gate-review-service.js +47 -8
  12. package/dist/backend/services/harness-service.js +41 -20
  13. package/dist/backend/services/message-service.js +30 -6
  14. package/dist/backend/services/pm-user-question.js +2 -1
  15. package/dist/backend/services/project-service.js +8 -2
  16. package/dist/backend/services/runtime-recovery-service.js +36 -9
  17. package/dist/backend/services/session-service.js +10 -19
  18. package/dist/backend/services/task-close-service.js +1 -0
  19. package/dist/backend/services/terminal-process-exit-service.js +4 -1
  20. package/dist/backend/services/workflow-control-service.js +55 -11
  21. package/dist/backend/templates/harness/architect-agent.js +2 -1
  22. package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -1
  23. package/dist/backend/templates/harness/architect-scaffold-worker-agent.js +8 -2
  24. package/dist/backend/templates/harness/architect-validation-worker-agent.js +2 -1
  25. package/dist/backend/templates/harness/architect-worker-frontmatter.js +33 -0
  26. package/dist/backend/templates/harness/check-scaffold-ledger.js +480 -511
  27. package/dist/backend/templates/harness/coder-worker-agent.js +1 -0
  28. package/dist/backend/templates/harness/gate-review.js +676 -643
  29. package/dist/backend/templates/harness/node-tool-runtime.js +102 -0
  30. package/dist/backend/templates/harness/resolve-durable-doc-assignment.js +49 -55
  31. package/dist/backend/templates/harness/restart-architect-skill.js +49 -58
  32. package/dist/backend/templates/harness/vcm-ask-user-skill.js +57 -59
  33. package/dist/backend/templates/harness/vcm-propose-memory-skill.js +4 -0
  34. package/dist/backend/templates/harness/vcm-report-harness-issue-skill.js +6 -2
  35. package/dist/backend/templates/harness/vcm-task-state-skill.js +66 -72
  36. package/dist/shared/types/app-settings.js +31 -0
  37. package/dist-frontend/assets/index-CShYYQQa.css +32 -0
  38. package/dist-frontend/assets/{index-DqANomt1.js → index-DOvvS76Q.js} +41 -41
  39. package/dist-frontend/index.html +2 -2
  40. package/package.json +1 -1
  41. package/scripts/harness-tools/check-durable-docs +332 -298
  42. package/scripts/harness-tools/generate-module-index +1047 -512
  43. package/scripts/harness-tools/generate-public-surface +1231 -1069
  44. package/scripts/harness-tools/package.json +3 -0
  45. package/scripts/harness-tools/run-long-check +608 -531
  46. package/scripts/harness-tools/vcm-artifact +181 -113
  47. package/scripts/harness-tools/vcm-bash-guard +839 -605
  48. package/scripts/harness-tools/vcm-subagent-guard +67 -63
  49. package/scripts/harness-tools/watch-job +313 -238
  50. package/dist-frontend/assets/index-xgiQeUD5.css +0 -32
@@ -1,536 +1,613 @@
1
- #!/usr/bin/env python3
2
- """Start one VCM long-running validation job with a worker-enforced ceiling.
3
-
4
- Usage:
5
- .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]
6
-
7
- Pass the validation executable and its arguments directly. Shell command-string
8
- wrappers are rejected because they can hide the validation command's exit code.
9
-
10
- The command runs in a detached worker process group. The worker itself
11
- enforces the job ceiling (--timeout, max 60m) and a supervision lease: when
12
- no foreground watcher renews .ai/vcm/jobs/<job-id>/lease, the worker kills
13
- the command process group and records the job as orphaned. Watch the job
14
- with .ai/tools/watch-job in the same turn.
15
-
16
- Only one validation job may be active at a time.
17
- """
18
- import json
19
- import os
20
- import shutil
21
- import signal
22
- import subprocess
23
- import sys
24
- import time
25
- import uuid
26
- from datetime import datetime, timezone
27
- from pathlib import Path
28
-
29
- MAX_TIMEOUT_SECONDS = 60 * 60
30
- DEFAULT_TIMEOUT_SECONDS = MAX_TIMEOUT_SECONDS
31
- QUEUED_STALE_SECONDS = 60
32
- ACTIVE_STATUSES = {"queued", "starting", "running"}
33
- DEFAULT_LEASE_START_GRACE_SECONDS = 300
34
- DEFAULT_LEASE_RENEW_GRACE_SECONDS = 120
35
- SHELL_EXECUTABLES = {"sh", "bash", "zsh", "dash", "ksh", "fish", "csh", "tcsh"}
36
-
37
-
38
- def root_dir() -> Path:
39
- return Path(__file__).resolve().parents[2]
40
-
41
-
42
- def jobs_root() -> Path:
43
- return root_dir() / ".ai/vcm/jobs"
44
-
45
-
46
- def now_iso() -> str:
47
- return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
48
-
49
-
50
- def write_json(path: Path, data: dict) -> None:
51
- tmp = path.with_suffix(path.suffix + ".tmp")
52
- tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
53
- tmp.replace(path)
54
-
55
-
56
- def read_optional_json(path: Path) -> dict | None:
57
- try:
58
- return json.loads(path.read_text())
59
- except (OSError, ValueError):
60
- return None
61
-
62
-
63
- def parse_duration(value: str) -> float:
64
- value = value.strip().lower()
65
- if value.endswith("ms"):
66
- return float(value[:-2]) / 1000
67
- if value.endswith("s"):
68
- return float(value[:-1])
69
- if value.endswith("m"):
70
- return float(value[:-1]) * 60
71
- if value.endswith("h"):
72
- return float(value[:-1]) * 3600
73
- return float(value)
74
-
75
-
76
- def env_seconds(name: str, default: float) -> float:
77
- raw = os.environ.get(name)
78
- if not raw:
79
- return default
80
- try:
81
- return max(1.0, float(raw))
82
- except ValueError:
83
- return default
84
-
85
-
86
- def job_id() -> str:
87
- timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
88
- return f"{timestamp}-{uuid.uuid4().hex[:8]}"
89
-
90
-
91
- def shell_command_wrapper(command: list[str]) -> str | None:
92
- if not command:
93
- return None
94
-
95
- candidates = range(len(command)) if Path(command[0]).name == "env" else range(1)
96
- for index in candidates:
97
- executable = Path(command[index]).name
98
- if executable not in SHELL_EXECUTABLES:
99
- continue
100
- if any(
101
- option.startswith("-")
102
- and not option.startswith("--")
103
- and "c" in option[1:]
104
- for option in command[index + 1:]
105
- ):
106
- return executable
107
- return None
108
-
109
-
110
- def process_exists(pid: int) -> bool:
111
- try:
112
- os.kill(pid, 0)
113
- return True
114
- except ProcessLookupError:
115
- return False
116
- except PermissionError:
117
- return True
118
-
119
-
120
- def process_group_exists(pgid: int) -> bool:
121
- try:
122
- os.killpg(pgid, 0)
123
- return True
124
- except ProcessLookupError:
125
- return False
126
- except PermissionError:
127
- return True
128
-
129
-
130
- def wait_for_process_group_exit(process: subprocess.Popen, timeout: float) -> bool:
131
- deadline = time.monotonic() + timeout
132
- while time.monotonic() < deadline:
133
- process.poll()
134
- if not process_group_exists(process.pid):
135
- return True
136
- time.sleep(0.05)
137
- process.poll()
138
- return not process_group_exists(process.pid)
139
-
140
-
141
- def stop_command(process: subprocess.Popen) -> str:
142
- try:
143
- os.killpg(process.pid, signal.SIGTERM)
144
- mode = "process-group"
145
- except ProcessLookupError:
146
- return "not-running"
147
- except PermissionError:
148
- process.terminate()
149
- mode = "process"
150
-
151
- if wait_for_process_group_exit(process, 2):
152
- return f"terminated-{mode}"
153
-
154
- try:
155
- os.killpg(process.pid, signal.SIGKILL)
156
- except (ProcessLookupError, PermissionError):
157
- if process.poll() is None:
158
- process.kill()
159
- return f"killed-{mode}" if wait_for_process_group_exit(process, 2) else f"cleanup-incomplete-{mode}"
160
-
161
-
162
- def lease_age_seconds(lease_path: Path) -> float | None:
163
- try:
164
- return max(0.0, time.time() - lease_path.stat().st_mtime)
165
- except OSError:
166
- return None
167
-
168
-
169
- def lease_iso(lease_path: Path) -> str | None:
170
- try:
171
- mtime = lease_path.stat().st_mtime
172
- except OSError:
173
- return None
174
- return datetime.fromtimestamp(mtime, timezone.utc).isoformat().replace("+00:00", "Z")
175
-
176
-
177
- def handoff_deadline(handoff_path: Path) -> tuple[float, str] | None:
178
- handoff = read_optional_json(handoff_path)
179
- if not handoff or not isinstance(handoff.get("nextWatchDueAt"), str):
180
- return None
181
- due_at = handoff["nextWatchDueAt"]
182
- try:
183
- deadline = datetime.fromisoformat(due_at.replace("Z", "+00:00")).timestamp()
184
- except ValueError:
185
- return None
186
- return deadline, due_at
187
-
188
-
189
- def clear_handoff(handoff_path: Path) -> None:
190
- try:
191
- handoff_path.unlink()
192
- except FileNotFoundError:
193
- pass
194
-
195
-
196
- def mark_stale(status_path: Path, status: dict, reason: str) -> None:
197
- stale = dict(status)
198
- stale.update({"status": "stale", "finishedAt": now_iso(), "staleReason": reason})
199
- write_json(status_path, stale)
200
-
201
-
202
- def find_active_job() -> dict | None:
203
- root = jobs_root()
204
- if not root.is_dir():
205
- return None
206
- for directory in sorted(root.iterdir()):
207
- if not directory.is_dir():
208
- continue
209
- status_path = directory / "status.json"
210
- status = read_optional_json(status_path)
211
- if not status or status.get("status") not in ACTIVE_STATUSES:
212
- continue
213
- pid = status.get("processId") or status.get("workerPid")
214
- if isinstance(pid, int):
215
- if process_exists(pid):
216
- return status
217
- mark_stale(status_path, status, "job process not running")
218
- continue
219
- try:
220
- age = time.time() - status_path.stat().st_mtime
221
- except OSError:
222
- continue
223
- if age < QUEUED_STALE_SECONDS:
224
- return status
225
- mark_stale(status_path, status, "queued job never started")
226
- return None
227
-
228
-
229
- def resolve_executable(executable: str) -> str:
230
- root = root_dir()
231
- if os.path.dirname(executable):
232
- candidate = root / executable
233
- if not candidate.exists():
234
- raise ValueError(f"executable does not exist: {candidate}")
235
- if not candidate.is_file():
236
- raise ValueError(f"executable is not a regular file: {candidate}")
237
- if not os.access(candidate, os.X_OK):
238
- raise ValueError(f"file is not executable: {candidate}")
239
- return str(candidate)
240
-
241
- # Popen runs in the worktree root, including for relative PATH entries.
242
- search_path = os.pathsep.join(str(root / entry) for entry in os.get_exec_path())
243
- resolved = shutil.which(executable, path=search_path)
244
- if resolved is None:
245
- raise ValueError(f"executable not found or not executable on PATH: {executable}")
246
- return resolved
247
-
248
-
249
- def record_launch_failure(directory: Path, status: dict, stage: str, error: OSError) -> str:
250
- message = (
251
- f"Unable to start {stage} for job {status['jobId']}: "
252
- f"command={status['command'][0]!r}, cwd={root_dir()}: {error}"
253
- )
254
- with (directory / "stderr.log").open("a", encoding="utf-8") as stderr:
255
- stderr.write(message + "\n")
256
- write_json(
257
- directory / "status.json",
258
- {**status, "status": "failed", "finishedAt": now_iso(), "exitCode": None},
259
- )
260
- return message
261
-
262
-
263
- def start_job(command: list[str], timeout_seconds: float) -> int:
264
- try:
265
- executable = resolve_executable(command[0])
266
- except (OSError, ValueError) as error:
267
- print(f"error: cannot start {command[0]!r} in {root_dir()}: {error}", file=sys.stderr)
268
- return 2
269
- command = [executable, *command[1:]]
270
-
271
- active = find_active_job()
272
- if active:
273
- print(
274
- f"error: validation job {active.get('jobId')} is already {active.get('status')}",
275
- file=sys.stderr,
276
- )
277
- print(f"watch it: .ai/tools/watch-job {active.get('jobId')}", file=sys.stderr)
278
- print("VCM allows one validation job at a time.", file=sys.stderr)
279
- return 3
280
-
281
- job = job_id()
282
- directory = jobs_root() / job
283
- directory.mkdir(parents=True, exist_ok=False)
284
-
285
- (directory / "command.json").write_text(
286
- json.dumps(
287
- {
288
- "command": command,
289
- "cwd": ".",
290
- "timeoutSeconds": timeout_seconds,
291
- "createdAt": now_iso(),
292
- },
293
- indent=2,
294
- sort_keys=True,
295
- )
296
- + "\n"
297
- )
298
- queued = {
299
- "jobId": job,
300
- "status": "queued",
301
- "command": command,
302
- "cwd": ".",
303
- "timeoutSeconds": timeout_seconds,
304
- "startedAt": None,
305
- "finishedAt": None,
306
- "exitCode": None,
307
- "durationSeconds": None,
308
- "workerPid": None,
309
- "processId": None,
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Start one VCM long-running validation job with a worker-enforced ceiling.
4
+ //
5
+ // Usage:
6
+ // .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]
7
+ //
8
+ // Pass the validation executable and its arguments directly. Shell command-string
9
+ // wrappers are rejected because they can hide the validation command's exit code.
10
+ //
11
+ // The command runs in a detached worker process group. The worker itself
12
+ // enforces the job ceiling (--timeout, max 60m) and a supervision lease: when
13
+ // no foreground watcher renews .ai/vcm/jobs/<job-id>/lease, the worker kills
14
+ // the command process group and records the job as orphaned. Watch the job
15
+ // with .ai/tools/watch-job in the same turn.
16
+ //
17
+ // Only one validation job may be active at a time.
18
+
19
+ const childProcess = require("node:child_process");
20
+ const crypto = require("node:crypto");
21
+ const fs = require("node:fs");
22
+ const os = require("node:os");
23
+ const path = require("node:path");
24
+
25
+ const MAX_TIMEOUT_SECONDS = 60 * 60;
26
+ const DEFAULT_TIMEOUT_SECONDS = MAX_TIMEOUT_SECONDS;
27
+ const QUEUED_STALE_SECONDS = 60;
28
+ const ACTIVE_STATUSES = new Set(["queued", "starting", "running"]);
29
+ const DEFAULT_LEASE_START_GRACE_SECONDS = 300;
30
+ const DEFAULT_LEASE_RENEW_GRACE_SECONDS = 120;
31
+ const SHELL_EXECUTABLES = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "csh", "tcsh"]);
32
+
33
+ function scriptPath() {
34
+ return fs.realpathSync(__filename);
35
+ }
36
+
37
+ function rootDir() {
38
+ return path.resolve(path.dirname(scriptPath()), "..", "..");
39
+ }
40
+
41
+ function jobsRoot() {
42
+ return path.join(rootDir(), ".ai/vcm/jobs");
43
+ }
44
+
45
+ function nowIso() {
46
+ return new Date().toISOString();
47
+ }
48
+
49
+ function sortKeys(value) {
50
+ if (Array.isArray(value)) {
51
+ return value.map(sortKeys);
52
+ }
53
+ if (value && typeof value === "object") {
54
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]));
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function jsonText(data) {
60
+ return JSON.stringify(sortKeys(data), null, 2) + "\n";
61
+ }
62
+
63
+ function writeJson(target, data) {
64
+ const temporary = target + ".tmp";
65
+ fs.writeFileSync(temporary, jsonText(data));
66
+ fs.renameSync(temporary, target);
67
+ }
68
+
69
+ function readOptionalJson(target) {
70
+ try {
71
+ return JSON.parse(fs.readFileSync(target, "utf8"));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function parseNumber(value) {
78
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) {
79
+ throw new Error("invalid number: " + value);
80
+ }
81
+ return Number(value);
82
+ }
83
+
84
+ function parseDuration(value) {
85
+ const normalized = value.trim().toLowerCase();
86
+ if (normalized.endsWith("ms")) {
87
+ return parseNumber(normalized.slice(0, -2)) / 1000;
88
+ }
89
+ if (normalized.endsWith("s")) {
90
+ return parseNumber(normalized.slice(0, -1));
91
+ }
92
+ if (normalized.endsWith("m")) {
93
+ return parseNumber(normalized.slice(0, -1)) * 60;
94
+ }
95
+ if (normalized.endsWith("h")) {
96
+ return parseNumber(normalized.slice(0, -1)) * 3600;
97
+ }
98
+ return parseNumber(normalized);
99
+ }
100
+
101
+ function envSeconds(name, fallback) {
102
+ const raw = process.env[name];
103
+ if (!raw) {
104
+ return fallback;
105
+ }
106
+ try {
107
+ return Math.max(1, parseNumber(raw));
108
+ } catch {
109
+ return fallback;
110
+ }
111
+ }
112
+
113
+ function newJobId() {
114
+ const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
115
+ return timestamp + "-" + crypto.randomUUID().replace(/-/g, "").slice(0, 8);
116
+ }
117
+
118
+ function shellCommandWrapper(command) {
119
+ if (command.length === 0) {
120
+ return null;
121
+ }
122
+ const candidates = path.basename(command[0]) === "env" ? command.map((_, index) => index) : [0];
123
+ for (const index of candidates) {
124
+ const executable = path.basename(command[index]);
125
+ if (!SHELL_EXECUTABLES.has(executable)) {
126
+ continue;
310
127
  }
311
- write_json(directory / "status.json", queued)
312
-
313
- try:
314
- subprocess.Popen(
315
- [sys.executable, str(Path(__file__).resolve()), "--worker", job],
316
- cwd=root_dir(),
317
- stdin=subprocess.DEVNULL,
318
- stdout=subprocess.DEVNULL,
319
- stderr=subprocess.DEVNULL,
320
- start_new_session=True,
321
- )
322
- except OSError as error:
323
- message = record_launch_failure(directory, queued, "job worker", error)
324
- print(f"error: {message}", file=sys.stderr)
325
- return 1
326
-
327
- print(f"job: {job}")
328
- print(f"ceiling: {int(timeout_seconds)}s (worker-enforced)")
329
- print(f"watch: .ai/tools/watch-job {job}")
330
- return 0
331
-
332
-
333
- def run_worker(job: str) -> int:
334
- directory = jobs_root() / job
335
- command_path = directory / "command.json"
336
- status_path = directory / "status.json"
337
- lease_path = directory / "lease"
338
- handoff_path = directory / "watch-handoff.json"
339
-
340
- payload = json.loads(command_path.read_text())
341
- command = payload["command"]
342
- timeout_seconds = min(
343
- float(payload.get("timeoutSeconds") or DEFAULT_TIMEOUT_SECONDS),
344
- MAX_TIMEOUT_SECONDS,
345
- )
346
- start_grace = env_seconds("VCM_JOB_LEASE_START_GRACE_SECONDS", DEFAULT_LEASE_START_GRACE_SECONDS)
347
- renew_grace = env_seconds("VCM_JOB_LEASE_RENEW_GRACE_SECONDS", DEFAULT_LEASE_RENEW_GRACE_SECONDS)
348
-
349
- worker_started = time.time()
350
- base = {
351
- "jobId": job,
352
- "command": command,
353
- "cwd": ".",
354
- "timeoutSeconds": timeout_seconds,
355
- "workerPid": os.getpid(),
128
+ if (command.slice(index + 1).some((option) => option.startsWith("-") && !option.startsWith("--") && option.slice(1).includes("c"))) {
129
+ return executable;
356
130
  }
357
- starting = {
358
- **base,
359
- "status": "starting",
360
- "startedAt": None,
361
- "finishedAt": None,
362
- "exitCode": None,
363
- "durationSeconds": None,
364
- "processId": None,
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function signalExists(target) {
136
+ try {
137
+ process.kill(target, 0);
138
+ return true;
139
+ } catch (error) {
140
+ return error.code === "EPERM";
141
+ }
142
+ }
143
+
144
+ function sleep(milliseconds) {
145
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
146
+ }
147
+
148
+ async function waitForProcessGroupExit(child, timeoutSeconds) {
149
+ const deadline = Date.now() + timeoutSeconds * 1000;
150
+ while (Date.now() < deadline) {
151
+ if (!signalExists(-child.pid)) {
152
+ return true;
365
153
  }
366
- write_json(status_path, starting)
367
-
368
- started = time.time()
369
- started_at = now_iso()
370
- verdict = None
371
- with (directory / "stdout.log").open("wb") as stdout, (directory / "stderr.log").open("wb") as stderr:
372
- try:
373
- process = subprocess.Popen(
374
- command,
375
- cwd=root_dir(),
376
- stdout=stdout,
377
- stderr=stderr,
378
- start_new_session=True,
379
- )
380
- except OSError as error:
381
- record_launch_failure(directory, starting, "validation command", error)
382
- return 1
383
- running = {
384
- **base,
385
- "status": "running",
386
- "startedAt": started_at,
387
- "finishedAt": None,
388
- "exitCode": None,
389
- "durationSeconds": None,
390
- "processId": process.pid,
154
+ await sleep(50);
155
+ }
156
+ return !signalExists(-child.pid);
157
+ }
158
+
159
+ async function stopCommand(child) {
160
+ let mode;
161
+ try {
162
+ process.kill(-child.pid, "SIGTERM");
163
+ mode = "process-group";
164
+ } catch (error) {
165
+ if (error.code === "ESRCH") {
166
+ return "not-running";
167
+ }
168
+ child.kill("SIGTERM");
169
+ mode = "process";
170
+ }
171
+
172
+ if (await waitForProcessGroupExit(child, 2)) {
173
+ return "terminated-" + mode;
174
+ }
175
+
176
+ try {
177
+ process.kill(-child.pid, "SIGKILL");
178
+ } catch {
179
+ if (child.exitCode === null && child.signalCode === null) {
180
+ child.kill("SIGKILL");
181
+ }
182
+ }
183
+ return (await waitForProcessGroupExit(child, 2)) ? "killed-" + mode : "cleanup-incomplete-" + mode;
184
+ }
185
+
186
+ function leaseAgeSeconds(leasePath) {
187
+ try {
188
+ return Math.max(0, (Date.now() - fs.statSync(leasePath).mtimeMs) / 1000);
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ function leaseIso(leasePath) {
195
+ try {
196
+ return new Date(fs.statSync(leasePath).mtimeMs).toISOString();
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function handoffDeadline(handoffPath) {
203
+ const handoff = readOptionalJson(handoffPath);
204
+ if (!handoff || typeof handoff.nextWatchDueAt !== "string") {
205
+ return null;
206
+ }
207
+ const deadline = Date.parse(handoff.nextWatchDueAt);
208
+ return Number.isNaN(deadline) ? null : { deadline, dueAt: handoff.nextWatchDueAt };
209
+ }
210
+
211
+ function clearHandoff(handoffPath) {
212
+ fs.rmSync(handoffPath, { force: true });
213
+ }
214
+
215
+ function markStale(statusPath, status, reason) {
216
+ writeJson(statusPath, { ...status, status: "stale", finishedAt: nowIso(), staleReason: reason });
217
+ }
218
+
219
+ function findActiveJob() {
220
+ const root = jobsRoot();
221
+ let entries;
222
+ try {
223
+ entries = fs.readdirSync(root, { withFileTypes: true });
224
+ } catch {
225
+ return null;
226
+ }
227
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
228
+ if (!entry.isDirectory()) {
229
+ continue;
230
+ }
231
+ const statusPath = path.join(root, entry.name, "status.json");
232
+ const status = readOptionalJson(statusPath);
233
+ if (!status || !ACTIVE_STATUSES.has(status.status)) {
234
+ continue;
235
+ }
236
+ const pid = status.processId || status.workerPid;
237
+ if (Number.isInteger(pid)) {
238
+ if (signalExists(pid)) {
239
+ return status;
240
+ }
241
+ markStale(statusPath, status, "job process not running");
242
+ continue;
243
+ }
244
+ let age;
245
+ try {
246
+ age = (Date.now() - fs.statSync(statusPath).mtimeMs) / 1000;
247
+ } catch {
248
+ continue;
249
+ }
250
+ if (age < QUEUED_STALE_SECONDS) {
251
+ return status;
252
+ }
253
+ markStale(statusPath, status, "queued job never started");
254
+ }
255
+ return null;
256
+ }
257
+
258
+ function isExecutableFile(candidate) {
259
+ try {
260
+ if (!fs.statSync(candidate).isFile()) {
261
+ return false;
262
+ }
263
+ fs.accessSync(candidate, fs.constants.X_OK);
264
+ return true;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ function resolveExecutable(executable) {
271
+ const root = rootDir();
272
+ if (executable.includes("/")) {
273
+ const candidate = path.resolve(root, executable);
274
+ let stat;
275
+ try {
276
+ stat = fs.statSync(candidate);
277
+ } catch {
278
+ throw new Error("executable does not exist: " + candidate);
279
+ }
280
+ if (!stat.isFile()) {
281
+ throw new Error("executable is not a regular file: " + candidate);
282
+ }
283
+ try {
284
+ fs.accessSync(candidate, fs.constants.X_OK);
285
+ } catch {
286
+ throw new Error("file is not executable: " + candidate);
287
+ }
288
+ return candidate;
289
+ }
290
+
291
+ // The command runs in the worktree root, including for relative PATH entries.
292
+ const entries = (process.env.PATH || "/bin:/usr/bin").split(path.delimiter);
293
+ for (const entry of entries) {
294
+ const candidate = path.join(path.resolve(root, entry), executable);
295
+ if (isExecutableFile(candidate)) {
296
+ return candidate;
297
+ }
298
+ }
299
+ throw new Error("executable not found or not executable on PATH: " + executable);
300
+ }
301
+
302
+ // Python's subprocess fails with ENOEXEC for a file the kernel cannot execute,
303
+ // while libuv falls back to /bin/sh on some platforms. Check the header so an
304
+ // unrunnable validation file is always a launch failure.
305
+ function assertExecutableFormat(executable) {
306
+ let header;
307
+ try {
308
+ const descriptor = fs.openSync(executable, "r");
309
+ header = Buffer.alloc(4);
310
+ fs.readSync(descriptor, header, 0, 4, 0);
311
+ fs.closeSync(descriptor);
312
+ } catch {
313
+ return;
314
+ }
315
+ const magic = header.readUInt32BE(0);
316
+ const known = header.subarray(0, 2).toString("latin1") === "#!"
317
+ || header.toString("latin1") === "\x7fELF"
318
+ || [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca].includes(magic)
319
+ || header.subarray(0, 2).toString("latin1") === "MZ";
320
+ if (!known) {
321
+ const error = new Error("Exec format error: '" + executable + "'");
322
+ error.code = "ENOEXEC";
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ function launchErrorText(error) {
328
+ return (error.code ? error.code + ": " : "") + error.message;
329
+ }
330
+
331
+ function recordLaunchFailure(directory, status, stage, error) {
332
+ const message = "Unable to start " + stage + " for job " + status.jobId + ": "
333
+ + "command='" + status.command[0] + "', cwd=" + rootDir() + ": " + launchErrorText(error);
334
+ fs.appendFileSync(path.join(directory, "stderr.log"), message + "\n", "utf8");
335
+ writeJson(path.join(directory, "status.json"), { ...status, status: "failed", finishedAt: nowIso(), exitCode: null });
336
+ return message;
337
+ }
338
+
339
+ // Resolves with the spawned child once it starts, or rejects with its launch error.
340
+ function spawnStarted(command, args, options) {
341
+ return new Promise((resolve, reject) => {
342
+ let child;
343
+ try {
344
+ child = childProcess.spawn(command, args, options);
345
+ } catch (error) {
346
+ reject(error);
347
+ return;
348
+ }
349
+ child.once("spawn", () => resolve(child));
350
+ child.once("error", reject);
351
+ });
352
+ }
353
+
354
+ async function startJob(commandArgs, timeoutSeconds) {
355
+ let executable;
356
+ try {
357
+ executable = resolveExecutable(commandArgs[0]);
358
+ } catch (error) {
359
+ process.stderr.write("error: cannot start '" + commandArgs[0] + "' in " + rootDir() + ": " + error.message + "\n");
360
+ return 2;
361
+ }
362
+ const command = [executable, ...commandArgs.slice(1)];
363
+
364
+ const active = findActiveJob();
365
+ if (active) {
366
+ process.stderr.write("error: validation job " + active.jobId + " is already " + active.status + "\n");
367
+ process.stderr.write("watch it: .ai/tools/watch-job " + active.jobId + "\n");
368
+ process.stderr.write("VCM allows one validation job at a time.\n");
369
+ return 3;
370
+ }
371
+
372
+ const job = newJobId();
373
+ const directory = path.join(jobsRoot(), job);
374
+ fs.mkdirSync(path.dirname(directory), { recursive: true });
375
+ fs.mkdirSync(directory);
376
+
377
+ fs.writeFileSync(path.join(directory, "command.json"), jsonText({
378
+ command,
379
+ cwd: ".",
380
+ timeoutSeconds,
381
+ createdAt: nowIso()
382
+ }));
383
+ const queued = {
384
+ jobId: job,
385
+ status: "queued",
386
+ command,
387
+ cwd: ".",
388
+ timeoutSeconds,
389
+ startedAt: null,
390
+ finishedAt: null,
391
+ exitCode: null,
392
+ durationSeconds: null,
393
+ workerPid: null,
394
+ processId: null
395
+ };
396
+ writeJson(path.join(directory, "status.json"), queued);
397
+
398
+ try {
399
+ const worker = await spawnStarted(process.execPath, [scriptPath(), "--worker", job], {
400
+ cwd: rootDir(),
401
+ stdio: "ignore",
402
+ detached: true
403
+ });
404
+ worker.unref();
405
+ } catch (error) {
406
+ const message = recordLaunchFailure(directory, queued, "job worker", error);
407
+ process.stderr.write("error: " + message + "\n");
408
+ return 1;
409
+ }
410
+
411
+ process.stdout.write("job: " + job + "\n");
412
+ process.stdout.write("ceiling: " + Math.trunc(timeoutSeconds) + "s (worker-enforced)\n");
413
+ process.stdout.write("watch: .ai/tools/watch-job " + job + "\n");
414
+ return 0;
415
+ }
416
+
417
+ function exitCodeOf(code, signal) {
418
+ if (code !== null && code !== undefined) {
419
+ return code;
420
+ }
421
+ return signal ? -(os.constants.signals[signal] || 1) : null;
422
+ }
423
+
424
+ async function runWorker(job) {
425
+ const directory = path.join(jobsRoot(), job);
426
+ const statusPath = path.join(directory, "status.json");
427
+ const leasePath = path.join(directory, "lease");
428
+ const handoffPath = path.join(directory, "watch-handoff.json");
429
+
430
+ const payload = JSON.parse(fs.readFileSync(path.join(directory, "command.json"), "utf8"));
431
+ const command = payload.command;
432
+ const timeoutSeconds = Math.min(Number(payload.timeoutSeconds) || DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS);
433
+ const startGrace = envSeconds("VCM_JOB_LEASE_START_GRACE_SECONDS", DEFAULT_LEASE_START_GRACE_SECONDS);
434
+ const renewGrace = envSeconds("VCM_JOB_LEASE_RENEW_GRACE_SECONDS", DEFAULT_LEASE_RENEW_GRACE_SECONDS);
435
+
436
+ const workerStarted = Date.now();
437
+ const base = { jobId: job, command, cwd: ".", timeoutSeconds, workerPid: process.pid };
438
+ const starting = {
439
+ ...base,
440
+ status: "starting",
441
+ startedAt: null,
442
+ finishedAt: null,
443
+ exitCode: null,
444
+ durationSeconds: null,
445
+ processId: null
446
+ };
447
+ writeJson(statusPath, starting);
448
+
449
+ const started = Date.now();
450
+ const startedAt = nowIso();
451
+ const stdout = fs.openSync(path.join(directory, "stdout.log"), "w");
452
+ const stderr = fs.openSync(path.join(directory, "stderr.log"), "w");
453
+ let child;
454
+ try {
455
+ assertExecutableFormat(command[0]);
456
+ child = await spawnStarted(command[0], command.slice(1), {
457
+ cwd: rootDir(),
458
+ stdio: ["ignore", stdout, stderr],
459
+ detached: true
460
+ });
461
+ } catch (error) {
462
+ fs.closeSync(stdout);
463
+ fs.closeSync(stderr);
464
+ recordLaunchFailure(directory, starting, "validation command", error);
465
+ return 1;
466
+ }
467
+ const exited = new Promise((resolve) => child.once("exit", (code, signal) => resolve(exitCodeOf(code, signal))));
468
+ let hasExited = false;
469
+ exited.then(() => {
470
+ hasExited = true;
471
+ });
472
+ writeJson(statusPath, {
473
+ ...base,
474
+ status: "running",
475
+ startedAt,
476
+ finishedAt: null,
477
+ exitCode: null,
478
+ durationSeconds: null,
479
+ processId: child.pid
480
+ });
481
+
482
+ let verdict = null;
483
+ while (!hasExited) {
484
+ if ((Date.now() - started) / 1000 >= timeoutSeconds) {
485
+ verdict = { kind: "timeout", extra: { processStopResult: await stopCommand(child) } };
486
+ break;
487
+ }
488
+
489
+ const leaseAge = leaseAgeSeconds(leasePath);
490
+ if (leaseAge === null) {
491
+ if ((Date.now() - workerStarted) / 1000 >= startGrace) {
492
+ verdict = {
493
+ kind: "orphaned",
494
+ extra: {
495
+ orphanReason: "no watcher within " + Math.trunc(startGrace) + "s",
496
+ lastWatchedAt: null,
497
+ processStopResult: await stopCommand(child)
498
+ }
499
+ };
500
+ break;
501
+ }
502
+ } else if (leaseAge >= renewGrace) {
503
+ const handoff = handoffDeadline(handoffPath);
504
+ if (handoff === null || Date.now() >= handoff.deadline) {
505
+ const extra = { lastWatchedAt: leaseIso(leasePath), processStopResult: await stopCommand(child) };
506
+ if (handoff !== null) {
507
+ extra.nextWatchDueAt = handoff.dueAt;
508
+ extra.orphanReason = "next watcher did not resume before handoff deadline";
509
+ } else {
510
+ extra.orphanReason = "lease not renewed for " + Math.trunc(leaseAge) + "s";
391
511
  }
392
- write_json(status_path, running)
393
-
394
- while True:
395
- exit_code = process.poll()
396
- if exit_code is not None:
397
- break
398
-
399
- if time.time() - started >= timeout_seconds:
400
- stop_result = stop_command(process)
401
- verdict = (
402
- "timeout",
403
- {"processStopResult": stop_result},
404
- )
405
- exit_code = process.wait()
406
- break
407
-
408
- lease_age = lease_age_seconds(lease_path)
409
- if lease_age is None:
410
- if time.time() - worker_started >= start_grace:
411
- stop_result = stop_command(process)
412
- verdict = (
413
- "orphaned",
414
- {
415
- "orphanReason": f"no watcher within {int(start_grace)}s",
416
- "lastWatchedAt": None,
417
- "processStopResult": stop_result,
418
- },
419
- )
420
- exit_code = process.wait()
421
- break
422
- elif lease_age >= renew_grace:
423
- handoff = handoff_deadline(handoff_path)
424
- if handoff is not None and time.time() < handoff[0]:
425
- time.sleep(1)
426
- continue
427
-
428
- stop_result = stop_command(process)
429
- extra = {
430
- "lastWatchedAt": lease_iso(lease_path),
431
- "processStopResult": stop_result,
432
- }
433
- if handoff is not None:
434
- extra.update({
435
- "nextWatchDueAt": handoff[1],
436
- "orphanReason": "next watcher did not resume before handoff deadline",
437
- })
438
- else:
439
- extra["orphanReason"] = f"lease not renewed for {int(lease_age)}s"
440
- verdict = ("orphaned", extra)
441
- exit_code = process.wait()
442
- break
443
-
444
- time.sleep(1)
445
-
446
- duration = round(time.time() - started, 3)
447
- clear_handoff(handoff_path)
448
- current = read_optional_json(status_path) or {}
449
- if current.get("status") in {"timeout", "orphaned", "stale"}:
450
- current["processExitCode"] = exit_code
451
- current["processFinishedAt"] = now_iso()
452
- current["processDurationSeconds"] = duration
453
- write_json(status_path, current)
454
- return 0
455
-
456
- final = {
457
- **base,
458
- "startedAt": started_at,
459
- "finishedAt": now_iso(),
460
- "durationSeconds": duration,
461
- "processId": process.pid,
512
+ verdict = { kind: "orphaned", extra };
513
+ break;
514
+ }
515
+ }
516
+
517
+ await Promise.race([sleep(1000), exited]);
518
+ }
519
+ const exitCode = await exited;
520
+ fs.closeSync(stdout);
521
+ fs.closeSync(stderr);
522
+
523
+ const duration = Math.round(Date.now() - started) / 1000;
524
+ clearHandoff(handoffPath);
525
+ const current = readOptionalJson(statusPath) || {};
526
+ if (["timeout", "orphaned", "stale"].includes(current.status)) {
527
+ writeJson(statusPath, {
528
+ ...current,
529
+ processExitCode: exitCode,
530
+ processFinishedAt: nowIso(),
531
+ processDurationSeconds: duration
532
+ });
533
+ return 0;
534
+ }
535
+
536
+ const final = { ...base, startedAt, finishedAt: nowIso(), durationSeconds: duration, processId: child.pid };
537
+ if (verdict === null) {
538
+ Object.assign(final, { status: exitCode === 0 ? "success" : "failed", exitCode });
539
+ } else {
540
+ Object.assign(final, { status: verdict.kind, exitCode: null, processExitCode: exitCode, ...verdict.extra });
541
+ }
542
+ writeJson(statusPath, final);
543
+ return 0;
544
+ }
545
+
546
+ async function main(argv) {
547
+ if (argv.length >= 2 && argv[0] === "--worker") {
548
+ return runWorker(argv[1]);
549
+ }
550
+
551
+ let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS;
552
+ let index = 0;
553
+ while (index < argv.length && argv[index] !== "--") {
554
+ const argument = argv[index];
555
+ let raw;
556
+ if (argument === "--timeout") {
557
+ if (index + 1 >= argv.length) {
558
+ process.stderr.write("error: --timeout needs a duration\n");
559
+ return 2;
560
+ }
561
+ raw = argv[index + 1];
562
+ index += 2;
563
+ } else if (argument.startsWith("--timeout=")) {
564
+ raw = argument.slice("--timeout=".length);
565
+ index += 1;
566
+ } else {
567
+ process.stderr.write("error: unknown option: " + argument + "\n");
568
+ return 2;
569
+ }
570
+ try {
571
+ timeoutSeconds = parseDuration(raw);
572
+ } catch {
573
+ process.stderr.write("error: invalid duration: " + raw + "\n");
574
+ return 2;
462
575
  }
463
- if verdict is None:
464
- final.update({"status": "success" if exit_code == 0 else "failed", "exitCode": exit_code})
465
- else:
466
- kind, extra = verdict
467
- final.update({"status": kind, "exitCode": None, "processExitCode": exit_code, **extra})
468
- write_json(status_path, final)
469
- return 0
470
-
471
-
472
- def main() -> int:
473
- argv = sys.argv[1:]
474
- if len(argv) >= 2 and argv[0] == "--worker":
475
- return run_worker(argv[1])
476
-
477
- timeout_seconds = DEFAULT_TIMEOUT_SECONDS
478
- index = 0
479
- while index < len(argv) and argv[index] != "--":
480
- arg = argv[index]
481
- raw = None
482
- if arg == "--timeout":
483
- if index + 1 >= len(argv):
484
- print("error: --timeout needs a duration", file=sys.stderr)
485
- return 2
486
- raw = argv[index + 1]
487
- index += 2
488
- elif arg.startswith("--timeout="):
489
- raw = arg[len("--timeout="):]
490
- index += 1
491
- else:
492
- print(f"error: unknown option: {arg}", file=sys.stderr)
493
- return 2
494
- try:
495
- timeout_seconds = parse_duration(raw)
496
- except ValueError:
497
- print(f"error: invalid duration: {raw}", file=sys.stderr)
498
- return 2
499
-
500
- if index >= len(argv) or argv[index] != "--" or index + 1 >= len(argv):
501
- print(
502
- "Usage: .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]",
503
- file=sys.stderr,
504
- )
505
- return 2
506
- if timeout_seconds <= 0:
507
- print("error: timeout must be positive", file=sys.stderr)
508
- return 2
509
- if timeout_seconds > MAX_TIMEOUT_SECONDS:
510
- print(
511
- "error: timeout exceeds maximum 60m; split the work or get user approval",
512
- file=sys.stderr,
513
- )
514
- return 2
515
-
516
- command = argv[index + 1:]
517
- shell_wrapper = shell_command_wrapper(command)
518
- if shell_wrapper:
519
- print(
520
- f"error: {shell_wrapper} command-string wrappers are not allowed; "
521
- "pass the validation executable and its arguments directly so its "
522
- "exit code remains authoritative",
523
- file=sys.stderr,
524
- )
525
- print(
526
- "run-long-check already captures stdout/stderr; do not add pipelines "
527
- "or trailing commands to trim output",
528
- file=sys.stderr,
529
- )
530
- return 2
531
-
532
- return start_job(command, timeout_seconds)
533
-
534
-
535
- if __name__ == "__main__":
536
- raise SystemExit(main())
576
+ }
577
+
578
+ if (index >= argv.length || argv[index] !== "--" || index + 1 >= argv.length) {
579
+ process.stderr.write("Usage: .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]\n");
580
+ return 2;
581
+ }
582
+ if (timeoutSeconds <= 0) {
583
+ process.stderr.write("error: timeout must be positive\n");
584
+ return 2;
585
+ }
586
+ if (timeoutSeconds > MAX_TIMEOUT_SECONDS) {
587
+ process.stderr.write("error: timeout exceeds maximum 60m; split the work or get user approval\n");
588
+ return 2;
589
+ }
590
+
591
+ const command = argv.slice(index + 1);
592
+ const shellWrapper = shellCommandWrapper(command);
593
+ if (shellWrapper) {
594
+ process.stderr.write(
595
+ "error: " + shellWrapper + " command-string wrappers are not allowed; "
596
+ + "pass the validation executable and its arguments directly so its "
597
+ + "exit code remains authoritative\n"
598
+ );
599
+ process.stderr.write(
600
+ "run-long-check already captures stdout/stderr; do not add pipelines "
601
+ + "or trailing commands to trim output\n"
602
+ );
603
+ return 2;
604
+ }
605
+
606
+ return startJob(command, timeoutSeconds);
607
+ }
608
+
609
+ if (require.main === module) {
610
+ main(process.argv.slice(2)).then((code) => process.exit(code));
611
+ } else {
612
+ module.exports = { startJob };
613
+ }