vibe-coding-master 0.8.13 → 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.
- package/README.md +7 -3
- package/dist/backend/cli/install-vcm-harness.js +57 -18
- package/dist/backend/gateway/gateway-service.js +5 -3
- package/dist/backend/runtime/terminal-submit.js +1 -0
- package/dist/backend/server.js +14 -4
- package/dist/backend/services/app-settings-service.js +3 -2
- package/dist/backend/services/auto-memory-service.js +4 -3
- package/dist/backend/services/claude-hook-service.js +6 -0
- package/dist/backend/services/claude-transcript-service.js +2 -13
- package/dist/backend/services/claude-version-check.js +46 -0
- package/dist/backend/services/gate-review-service.js +47 -8
- package/dist/backend/services/harness-service.js +41 -20
- package/dist/backend/services/message-service.js +30 -6
- package/dist/backend/services/project-service.js +8 -2
- package/dist/backend/services/runtime-recovery-service.js +36 -9
- package/dist/backend/services/session-service.js +10 -19
- package/dist/backend/services/task-close-service.js +1 -0
- package/dist/backend/services/terminal-process-exit-service.js +4 -1
- package/dist/backend/services/workflow-control-service.js +55 -11
- package/dist/backend/templates/harness/architect-worker-frontmatter.js +33 -0
- package/dist/backend/templates/harness/check-scaffold-ledger.js +480 -511
- package/dist/backend/templates/harness/gate-review.js +676 -643
- package/dist/backend/templates/harness/node-tool-runtime.js +102 -0
- package/dist/backend/templates/harness/resolve-durable-doc-assignment.js +49 -55
- package/dist/backend/templates/harness/restart-architect-skill.js +49 -58
- package/dist/backend/templates/harness/vcm-ask-user-skill.js +57 -59
- package/dist/backend/templates/harness/vcm-task-state-skill.js +66 -72
- package/dist/shared/types/app-settings.js +31 -0
- package/dist-frontend/assets/index-CShYYQQa.css +32 -0
- package/dist-frontend/assets/{index-DqANomt1.js → index-DOvvS76Q.js} +41 -41
- package/dist-frontend/index.html +2 -2
- package/package.json +1 -1
- package/scripts/harness-tools/check-durable-docs +332 -298
- package/scripts/harness-tools/generate-module-index +1047 -512
- package/scripts/harness-tools/generate-public-surface +1231 -1069
- package/scripts/harness-tools/package.json +3 -0
- package/scripts/harness-tools/run-long-check +608 -531
- package/scripts/harness-tools/vcm-artifact +181 -113
- package/scripts/harness-tools/vcm-bash-guard +839 -605
- package/scripts/harness-tools/vcm-subagent-guard +67 -63
- package/scripts/harness-tools/watch-job +313 -238
- package/dist-frontend/assets/index-xgiQeUD5.css +0 -32
|
@@ -1,536 +1,613 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
+
}
|