vibe-coding-master 0.2.7 → 0.2.9

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 (34) hide show
  1. package/README.md +47 -13
  2. package/dist/backend/api/claude-hook-routes.js +7 -1
  3. package/dist/backend/gateway/gateway-command-parser.js +4 -0
  4. package/dist/backend/gateway/gateway-service.js +263 -32
  5. package/dist/backend/gateway/gateway-settings-service.js +34 -0
  6. package/dist/backend/server.js +3 -1
  7. package/dist/backend/services/claude-hook-service.js +33 -3
  8. package/dist/backend/services/harness-service.js +37 -32
  9. package/dist/backend/services/job-guard-service.js +126 -0
  10. package/dist/backend/services/message-service.js +1 -1
  11. package/dist/backend/templates/harness/architect-agent.js +19 -8
  12. package/dist/backend/templates/harness/claude-root.js +7 -11
  13. package/dist/backend/templates/harness/coder-agent.js +45 -17
  14. package/dist/backend/templates/harness/gitignore.js +3 -2
  15. package/dist/backend/templates/harness/project-manager-agent.js +16 -11
  16. package/dist/backend/templates/harness/reviewer-agent.js +25 -28
  17. package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +42 -31
  18. package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +37 -18
  19. package/dist-frontend/assets/index-DC9-SB7F.css +32 -0
  20. package/dist-frontend/assets/{index-DHuS-DYr.js → index-DDrcJLOG.js} +37 -37
  21. package/dist-frontend/index.html +2 -2
  22. package/docs/full-harness-baseline.md +7 -5
  23. package/docs/gateway-design.md +40 -6
  24. package/docs/product-design.md +11 -2
  25. package/docs/vcm-cc-best-practices.md +11 -4
  26. package/package.json +1 -1
  27. package/scripts/harness-tools/run-long-check +401 -0
  28. package/scripts/harness-tools/vcm-bash-guard +107 -0
  29. package/scripts/harness-tools/watch-job +204 -0
  30. package/scripts/install-vcm-harness.mjs +93 -387
  31. package/scripts/uninstall-vcm-harness.mjs +18 -1
  32. package/scripts/verify-package.mjs +3 -0
  33. package/dist/backend/templates/harness/known-issues-doc.js +0 -22
  34. package/dist-frontend/assets/index-7lq6YPCq.css +0 -32
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>VibeCodingMaster</title>
7
- <script type="module" crossorigin src="/assets/index-DHuS-DYr.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-7lq6YPCq.css">
7
+ <script type="module" crossorigin src="/assets/index-DDrcJLOG.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DC9-SB7F.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -91,13 +91,14 @@ These entries match the current `example/rust-layered/.ai/vcm-harness-manifest.j
91
91
  | Skill directory | `.claude/skills/vcm-harness-bootstrap/` | VCM-created directory | Conditional long-term | Claude Code registration directory for the harness-bootstrap skill. |
92
92
  | Skill | `.claude/skills/vcm-route-message/SKILL.md` | whole-file | Conditional long-term | Route-file authoring protocol. |
93
93
  | Skill | `.claude/skills/vcm-final-acceptance/SKILL.md` | whole-file | Conditional long-term | PM final evidence audit. |
94
- | Skill | `.claude/skills/vcm-long-running-validation/SKILL.md` | whole-file | Conditional long-term | Role-independent long-running command protocol with 60 minute maximum timeout. |
94
+ | Skill | `.claude/skills/vcm-long-running-validation/SKILL.md` | whole-file | Conditional long-term | Role-independent long-running command protocol: worker-enforced 60 minute ceiling, supervision lease, windowed foreground watching. |
95
95
  | Skill | `.claude/skills/vcm-harness-bootstrap/SKILL.md` | whole-file | Conditional long-term | AI-assisted project understanding procedure. |
96
96
  | Harness tool directory | `.ai/tools/` | VCM-created directory | Long-term | Repo-local harness tools. |
97
97
  | Generated-context tool | `.ai/tools/generate-module-index` | whole-file | Long-term | Generates `.ai/generated/module-index.json`. |
98
98
  | Generated-context tool | `.ai/tools/generate-public-surface` | whole-file | Long-term | Generates `.ai/generated/public-surface.json`. |
99
- | Runtime tool | `.ai/tools/run-long-check` | whole-file | Conditional long-term | Starts the only VCM-allowed file-backed background job for long-running validation. |
100
- | Runtime tool | `.ai/tools/watch-job` | whole-file | Conditional long-term | Bounded watcher for long-running command jobs; rejects timeouts over 60 minutes. |
99
+ | Runtime tool | `.ai/tools/run-long-check` | whole-file | Conditional long-term | Starts the only VCM-allowed file-backed validation job; its worker enforces the job ceiling (max 60m) and a supervision lease, and refuses concurrent jobs. |
100
+ | Runtime tool | `.ai/tools/watch-job` | whole-file | Conditional long-term | Foreground watcher and lease renewer; watches in windows up to 8 minutes and exits 125 while the job still runs instead of killing it. |
101
+ | Runtime tool | `.ai/tools/vcm-bash-guard` | whole-file | Conditional long-term | PreToolUse hook guard; denies `run_in_background`, `nohup`, `setsid`, `disown`, and `&` background Bash in VCM role sessions. |
101
102
  | Generated context directory | `.ai/generated/` | VCM-created directory | Long-term | Derived context artifacts. |
102
103
  | Generated context | `.ai/generated/module-index.json` | derived artifact | Derived | Regenerated by `generate-module-index`; do not hand-edit as source truth. |
103
104
  | Generated context | `.ai/generated/public-surface.json` | derived artifact | Derived | Regenerated by `generate-public-surface`; do not hand-edit as source truth. |
@@ -167,7 +168,7 @@ The current example installs these skills:
167
168
 
168
169
  - `vcm-route-message`: route-file write protocol.
169
170
  - `vcm-final-acceptance`: final PM evidence audit.
170
- - `vcm-long-running-validation`: role-independent long-running command protocol with a 60 minute maximum timeout.
171
+ - `vcm-long-running-validation`: role-independent long-running command protocol with a worker-enforced 60 minute ceiling, supervision lease, and windowed foreground watching.
171
172
  - `vcm-harness-bootstrap`: one-time or occasional project-understanding, generated-context refresh, and durable-doc bootstrap procedure.
172
173
 
173
174
  The current example does not install a separate `vcm-docs-sync` skill. Docs sync
@@ -176,13 +177,14 @@ is expressed in the architect role rules and writes
176
177
 
177
178
  ## Current Tools
178
179
 
179
- The current example keeps only four `.ai/tools/` files:
180
+ The current example keeps only five `.ai/tools/` files:
180
181
 
181
182
  ```text
182
183
  .ai/tools/generate-module-index
183
184
  .ai/tools/generate-public-surface
184
185
  .ai/tools/run-long-check
185
186
  .ai/tools/watch-job
187
+ .ai/tools/vcm-bash-guard
186
188
  ```
187
189
 
188
190
  The generated-context tools currently support Rust projects only. The fixed
@@ -29,6 +29,12 @@ Product rules:
29
29
  - When the desktop UI has a current task selected, Gateway should adopt that
30
30
  project/task context automatically instead of requiring `/tasks` and
31
31
  `/use-task` first.
32
+ - After QR binding succeeds, VCM keeps a long-polling Gateway connection even
33
+ when Gateway is disabled. Disabled Gateway accepts only `/help`, `/start`,
34
+ `/status`, `/projects`, and `/tasks`.
35
+ - VCM stores the latest PM reply for each task in local Gateway state. When
36
+ `/start` enables Gateway and the current task has a cached PM reply, Gateway
37
+ returns that reply immediately so the phone user sees the current task state.
32
38
  - When Gateway is enabled, browser Flow pause alert should be forced off because
33
39
  Weixin becomes the notification path and browser modal alerts can block the
34
40
  workflow.
@@ -45,7 +51,9 @@ The short product sentence is:
45
51
  One phone DM binds to one desktop VCM; the phone can select project/task context,
46
52
  pull the connected base repository, create and initialize a task through the
47
53
  saved launch template, send ordinary messages to the current task's PM, receive
48
- translated PM replies, and close completed tasks while gateway is enabled.
54
+ translated PM replies, and close completed tasks while gateway is enabled; when
55
+ gateway is disabled, the bound phone can still run `/start` and read-only status
56
+ commands.
49
57
  ```
50
58
 
51
59
  ## Binding Model
@@ -86,8 +94,9 @@ translation enabled/disabled
86
94
  saved launch template
87
95
  ```
88
96
 
89
- Plain text messages are sent to the PM session of the current task. If no
90
- project or task is selected, gateway replies with a short setup hint.
97
+ Plain text messages are sent to the PM session of the current task only when
98
+ Gateway is enabled. If no project or task is selected, gateway replies with a
99
+ short setup hint.
91
100
 
92
101
  VCM normally runs one project and one task at a time. When Gateway is enabled,
93
102
  or when `/status` is called, VCM should refresh Gateway context from the
@@ -102,6 +111,8 @@ MVP commands:
102
111
 
103
112
  ```text
104
113
  /help
114
+ /start
115
+ /retry
105
116
  /status
106
117
  /projects
107
118
  /use-project <index-or-path>
@@ -116,7 +127,30 @@ MVP commands:
116
127
  ```
117
128
 
118
129
  Plain text that does not start with `/` is treated as a PM message for the
119
- current task.
130
+ current task only when Gateway is enabled.
131
+
132
+ When Gateway is disabled but still bound, only this subset is accepted:
133
+
134
+ ```text
135
+ /help
136
+ /start
137
+ /status
138
+ /projects
139
+ /tasks
140
+ ```
141
+
142
+ `/start` enables Gateway from the bound Weixin DM. If there is a current task
143
+ and VCM has cached a latest PM reply for that task, `/start` includes that reply
144
+ in the command response. All plain text, task-changing commands,
145
+ project-changing commands, translation toggles, and repository pull commands
146
+ require Gateway to be enabled.
147
+
148
+ When Gateway output translation is enabled, PM replies are translated before
149
+ being sent to Weixin. If translation fails or times out, Gateway sends a
150
+ translation failure notice instead of the English source. The latest failed
151
+ output translation is kept in memory only, and `/retry` retries that source
152
+ content. Successful retry clears the memory item; failed retry keeps it for a
153
+ later `/retry`.
120
154
 
121
155
  Task lifecycle commands:
122
156
 
@@ -621,8 +655,8 @@ Validation:
621
655
 
622
656
  ### Phase 3: Inbound Context Commands
623
657
 
624
- - Implement `/help`, `/status`, `/projects`, `/use-project`, `/tasks`,
625
- `/use-task`, `/translate on`, and `/translate off`.
658
+ - Implement `/help`, `/start`, `/retry`, `/status`, `/projects`,
659
+ `/use-project`, `/tasks`, `/use-task`, `/translate on`, and `/translate off`.
626
660
  - Implement bound identity check.
627
661
  - Implement persistent inbound message dedupe.
628
662
  - Reply with short command results through iLink.
@@ -556,7 +556,7 @@ VCM uses `UserPromptSubmit` as the Claude Code acceptance signal. A successful P
556
556
 
557
557
  The injected role rules require asynchronous file messaging: after writing or updating a route file, the role must end the current Claude Code turn and wait for VCM to deliver a later reply. Roles should use `.claude/skills/vcm-route-message/SKILL.md` to author route files. Roles must not poll files, start shell loops, keep the turn open waiting for another role to answer, paste directly into another role terminal, or use Claude Code Task/Subagent for VCM role delegation.
558
558
 
559
- Roles must not start background jobs. The only allowed background job is `.ai/tools/run-long-check` when used through `vcm-long-running-validation`; `watch-job` rejects timeouts over 60 minutes.
559
+ Roles must not run background Bash; a `PreToolUse` hook (`.ai/tools/vcm-bash-guard`) denies `run_in_background`, `nohup`, `setsid`, `disown`, and trailing `&`. The only sanctioned long-running mechanism is `.ai/tools/run-long-check` plus `.ai/tools/watch-job` through `vcm-long-running-validation`: the detached job worker enforces the 60 minute ceiling and a supervision lease that kills unwatched jobs, `watch-job` renews the lease in foreground windows of up to 8 minutes (exit `125` means call it again in the same turn), and the VCM backend blocks a role's turn-end while one of its validation jobs is still running.
560
560
 
561
561
  There is no `vcmctl` in the target design. Hook entrypoints are direct HTTP from Claude Code hooks to the local VCM backend.
562
562
 
@@ -670,7 +670,7 @@ Translation panel `Auto-send` is separate from task `Auto orchestration`:
670
670
  - `Auto-send` on: translate and send if there is no translation warning.
671
671
  - `Auto-send` off: translate to English draft and wait for user send.
672
672
 
673
- Task `Auto orchestration` is a compact selected/unselected button in the role console toolbar. `Translate` is a global task header toggle next to `Close Task`; it opens/closes the translation split for all role consoles, so switching roles keeps the same translation setting.
673
+ Task `Auto orchestration` is a compact selected/unselected button in the role console toolbar. New tasks default to auto orchestration. `Translate` is a global task header toggle next to `Close Task`; it opens/closes the translation split for all role consoles, so switching roles keeps the same translation setting.
674
674
 
675
675
  ## 14. Mobile Gateway
676
676
 
@@ -683,6 +683,12 @@ Gateway product rules:
683
683
  - Binding is not tied to one project or one task.
684
684
  - The bound phone can select among the projects and tasks available to the
685
685
  desktop VCM instance.
686
+ - After QR binding succeeds, VCM keeps a Gateway long-polling connection even
687
+ when Gateway is off; only `/help`, `/start`, `/status`, `/projects`, and
688
+ `/tasks` are accepted in that state. `/start` turns Gateway on from Weixin.
689
+ - VCM caches the latest PM reply per task locally. When `/start` turns Gateway
690
+ on and the current task has a cached PM reply, the response includes that
691
+ latest PM reply so the mobile user can resume with context.
686
692
  - Plain mobile text is sent only to the current task's `project-manager`.
687
693
  - Gateway never sends directly to `architect`, `coder`, or `reviewer`.
688
694
  - Gateway can push PM assistant replies to Weixin whenever gateway is enabled,
@@ -690,6 +696,9 @@ Gateway product rules:
690
696
  - When gateway translation is enabled, mobile Chinese input is translated to
691
697
  English before PM receives it, and PM English replies are translated to
692
698
  Chinese before Weixin receives them.
699
+ - If PM reply translation fails or times out, Gateway sends a translation
700
+ failure notice instead of the English source. The bound phone can send
701
+ `/retry` to retry the latest failed output translation kept in memory.
693
702
  - The PM prompt does not include the original Chinese text.
694
703
  - There is no multi-user allowlist. The security model is one bound DM identity.
695
704
 
@@ -50,6 +50,7 @@ CLAUDE.md
50
50
  .ai/tools/generate-public-surface
51
51
  .ai/tools/run-long-check
52
52
  .ai/tools/watch-job
53
+ .ai/tools/vcm-bash-guard
53
54
  .github/pull_request_template.md
54
55
  ```
55
56
 
@@ -280,10 +281,16 @@ This skill is role-independent. It only handles long-running command execution,
280
281
  bounded waiting, file-backed status, timeout, and log summaries. The caller
281
282
  decides where to record command evidence.
282
283
 
283
- VCM roles must not start background jobs. The only allowed background job is
284
- `.ai/tools/run-long-check` when used through `vcm-long-running-validation`.
285
- `vcm-long-running-validation` has a hard maximum timeout of 60 minutes; split
286
- larger validation/build work or ask the user before suggesting anything longer.
284
+ VCM roles must not run background Bash; a `PreToolUse` hook
285
+ (`.ai/tools/vcm-bash-guard`) denies `run_in_background`, `nohup`, `setsid`,
286
+ `disown`, and trailing `&`. The only sanctioned long-running mechanism is
287
+ `.ai/tools/run-long-check` plus `.ai/tools/watch-job` through
288
+ `vcm-long-running-validation`. The job worker enforces a hard 60 minute
289
+ ceiling and a supervision lease that kills unwatched jobs; `watch-job` watches
290
+ in foreground windows of up to 8 minutes (exit `125` means watch again now),
291
+ and the VCM backend blocks turn-end while a validation job is running. Split
292
+ larger validation/build work or ask the user before suggesting anything longer
293
+ than 60 minutes.
287
294
 
288
295
  ## 10. Generated Context
289
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-coding-master",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Local GUI session cockpit for Claude Code role sessions.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,401 @@
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
+ The command runs in a detached worker process group. The worker itself
8
+ enforces the job ceiling (--timeout, max 60m) and a supervision lease: when
9
+ no foreground watcher renews .ai/vcm/jobs/<job-id>/lease, the worker kills
10
+ the command process group and records the job as orphaned. Watch the job
11
+ with .ai/tools/watch-job in the same turn.
12
+
13
+ Only one validation job may be active at a time.
14
+ """
15
+ import json
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import uuid
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ MAX_TIMEOUT_SECONDS = 60 * 60
26
+ DEFAULT_TIMEOUT_SECONDS = MAX_TIMEOUT_SECONDS
27
+ QUEUED_STALE_SECONDS = 60
28
+ ACTIVE_STATUSES = {"queued", "starting", "running"}
29
+ DEFAULT_LEASE_START_GRACE_SECONDS = 300
30
+ DEFAULT_LEASE_RENEW_GRACE_SECONDS = 120
31
+
32
+
33
+ def root_dir() -> Path:
34
+ return Path(__file__).resolve().parents[2]
35
+
36
+
37
+ def jobs_root() -> Path:
38
+ return root_dir() / ".ai/vcm/jobs"
39
+
40
+
41
+ def now_iso() -> str:
42
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
43
+
44
+
45
+ def write_json(path: Path, data: dict) -> None:
46
+ tmp = path.with_suffix(path.suffix + ".tmp")
47
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
48
+ tmp.replace(path)
49
+
50
+
51
+ def read_optional_json(path: Path) -> dict | None:
52
+ try:
53
+ return json.loads(path.read_text())
54
+ except (OSError, ValueError):
55
+ return None
56
+
57
+
58
+ def parse_duration(value: str) -> float:
59
+ value = value.strip().lower()
60
+ if value.endswith("ms"):
61
+ return float(value[:-2]) / 1000
62
+ if value.endswith("s"):
63
+ return float(value[:-1])
64
+ if value.endswith("m"):
65
+ return float(value[:-1]) * 60
66
+ if value.endswith("h"):
67
+ return float(value[:-1]) * 3600
68
+ return float(value)
69
+
70
+
71
+ def env_seconds(name: str, default: float) -> float:
72
+ raw = os.environ.get(name)
73
+ if not raw:
74
+ return default
75
+ try:
76
+ return max(1.0, float(raw))
77
+ except ValueError:
78
+ return default
79
+
80
+
81
+ def job_id() -> str:
82
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
83
+ return f"{timestamp}-{uuid.uuid4().hex[:8]}"
84
+
85
+
86
+ def process_exists(pid: int) -> bool:
87
+ try:
88
+ os.kill(pid, 0)
89
+ return True
90
+ except ProcessLookupError:
91
+ return False
92
+ except PermissionError:
93
+ return True
94
+
95
+
96
+ def stop_command(process: subprocess.Popen) -> str:
97
+ try:
98
+ os.killpg(process.pid, signal.SIGTERM)
99
+ mode = "process-group"
100
+ except ProcessLookupError:
101
+ return "not-running"
102
+ except PermissionError:
103
+ process.terminate()
104
+ mode = "process"
105
+
106
+ try:
107
+ process.wait(timeout=2)
108
+ return f"terminated-{mode}"
109
+ except subprocess.TimeoutExpired:
110
+ pass
111
+
112
+ try:
113
+ os.killpg(process.pid, signal.SIGKILL)
114
+ except (ProcessLookupError, PermissionError):
115
+ process.kill()
116
+ return f"killed-{mode}"
117
+
118
+
119
+ def lease_age_seconds(lease_path: Path) -> float | None:
120
+ try:
121
+ return max(0.0, time.time() - lease_path.stat().st_mtime)
122
+ except OSError:
123
+ return None
124
+
125
+
126
+ def lease_iso(lease_path: Path) -> str | None:
127
+ try:
128
+ mtime = lease_path.stat().st_mtime
129
+ except OSError:
130
+ return None
131
+ return datetime.fromtimestamp(mtime, timezone.utc).isoformat().replace("+00:00", "Z")
132
+
133
+
134
+ def mark_stale(status_path: Path, status: dict, reason: str) -> None:
135
+ stale = dict(status)
136
+ stale.update({"status": "stale", "finishedAt": now_iso(), "staleReason": reason})
137
+ write_json(status_path, stale)
138
+
139
+
140
+ def find_active_job() -> dict | None:
141
+ root = jobs_root()
142
+ if not root.is_dir():
143
+ return None
144
+ for directory in sorted(root.iterdir()):
145
+ if not directory.is_dir():
146
+ continue
147
+ status_path = directory / "status.json"
148
+ status = read_optional_json(status_path)
149
+ if not status or status.get("status") not in ACTIVE_STATUSES:
150
+ continue
151
+ pid = status.get("processId") or status.get("workerPid")
152
+ if isinstance(pid, int):
153
+ if process_exists(pid):
154
+ return status
155
+ mark_stale(status_path, status, "job process not running")
156
+ continue
157
+ try:
158
+ age = time.time() - status_path.stat().st_mtime
159
+ except OSError:
160
+ continue
161
+ if age < QUEUED_STALE_SECONDS:
162
+ return status
163
+ mark_stale(status_path, status, "queued job never started")
164
+ return None
165
+
166
+
167
+ def start_job(command: list[str], timeout_seconds: float) -> int:
168
+ active = find_active_job()
169
+ if active:
170
+ print(
171
+ f"error: validation job {active.get('jobId')} is already {active.get('status')}",
172
+ file=sys.stderr,
173
+ )
174
+ print(f"watch it: .ai/tools/watch-job {active.get('jobId')}", file=sys.stderr)
175
+ print("VCM allows one validation job at a time.", file=sys.stderr)
176
+ return 3
177
+
178
+ job = job_id()
179
+ directory = jobs_root() / job
180
+ directory.mkdir(parents=True, exist_ok=False)
181
+
182
+ (directory / "command.json").write_text(
183
+ json.dumps(
184
+ {
185
+ "command": command,
186
+ "cwd": ".",
187
+ "timeoutSeconds": timeout_seconds,
188
+ "createdAt": now_iso(),
189
+ },
190
+ indent=2,
191
+ sort_keys=True,
192
+ )
193
+ + "\n"
194
+ )
195
+ write_json(
196
+ directory / "status.json",
197
+ {
198
+ "jobId": job,
199
+ "status": "queued",
200
+ "command": command,
201
+ "cwd": ".",
202
+ "timeoutSeconds": timeout_seconds,
203
+ "startedAt": None,
204
+ "finishedAt": None,
205
+ "exitCode": None,
206
+ "durationSeconds": None,
207
+ "workerPid": None,
208
+ "processId": None,
209
+ },
210
+ )
211
+
212
+ subprocess.Popen(
213
+ [sys.executable, str(Path(__file__).resolve()), "--worker", job],
214
+ cwd=root_dir(),
215
+ stdin=subprocess.DEVNULL,
216
+ stdout=subprocess.DEVNULL,
217
+ stderr=subprocess.DEVNULL,
218
+ start_new_session=True,
219
+ )
220
+
221
+ print(f"job: {job}")
222
+ print(f"ceiling: {int(timeout_seconds)}s (worker-enforced)")
223
+ print(f"watch: .ai/tools/watch-job {job}")
224
+ return 0
225
+
226
+
227
+ def run_worker(job: str) -> int:
228
+ directory = jobs_root() / job
229
+ command_path = directory / "command.json"
230
+ status_path = directory / "status.json"
231
+ lease_path = directory / "lease"
232
+
233
+ payload = json.loads(command_path.read_text())
234
+ command = payload["command"]
235
+ timeout_seconds = min(
236
+ float(payload.get("timeoutSeconds") or DEFAULT_TIMEOUT_SECONDS),
237
+ MAX_TIMEOUT_SECONDS,
238
+ )
239
+ start_grace = env_seconds("VCM_JOB_LEASE_START_GRACE_SECONDS", DEFAULT_LEASE_START_GRACE_SECONDS)
240
+ renew_grace = env_seconds("VCM_JOB_LEASE_RENEW_GRACE_SECONDS", DEFAULT_LEASE_RENEW_GRACE_SECONDS)
241
+
242
+ worker_started = time.time()
243
+ base = {
244
+ "jobId": job,
245
+ "command": command,
246
+ "cwd": ".",
247
+ "timeoutSeconds": timeout_seconds,
248
+ "workerPid": os.getpid(),
249
+ }
250
+ write_json(
251
+ status_path,
252
+ {
253
+ **base,
254
+ "status": "starting",
255
+ "startedAt": None,
256
+ "finishedAt": None,
257
+ "exitCode": None,
258
+ "durationSeconds": None,
259
+ "processId": None,
260
+ },
261
+ )
262
+
263
+ started = time.time()
264
+ started_at = now_iso()
265
+ verdict = None
266
+ with (directory / "stdout.log").open("wb") as stdout, (directory / "stderr.log").open("wb") as stderr:
267
+ process = subprocess.Popen(
268
+ command,
269
+ cwd=root_dir(),
270
+ stdout=stdout,
271
+ stderr=stderr,
272
+ start_new_session=True,
273
+ )
274
+ running = {
275
+ **base,
276
+ "status": "running",
277
+ "startedAt": started_at,
278
+ "finishedAt": None,
279
+ "exitCode": None,
280
+ "durationSeconds": None,
281
+ "processId": process.pid,
282
+ }
283
+ write_json(status_path, running)
284
+
285
+ while True:
286
+ exit_code = process.poll()
287
+ if exit_code is not None:
288
+ break
289
+
290
+ if time.time() - started >= timeout_seconds:
291
+ stop_result = stop_command(process)
292
+ verdict = (
293
+ "timeout",
294
+ {"processStopResult": stop_result},
295
+ )
296
+ exit_code = process.wait()
297
+ break
298
+
299
+ lease_age = lease_age_seconds(lease_path)
300
+ if lease_age is None:
301
+ if time.time() - worker_started >= start_grace:
302
+ stop_result = stop_command(process)
303
+ verdict = (
304
+ "orphaned",
305
+ {
306
+ "orphanReason": f"no watcher within {int(start_grace)}s",
307
+ "lastWatchedAt": None,
308
+ "processStopResult": stop_result,
309
+ },
310
+ )
311
+ exit_code = process.wait()
312
+ break
313
+ elif lease_age >= renew_grace:
314
+ stop_result = stop_command(process)
315
+ verdict = (
316
+ "orphaned",
317
+ {
318
+ "orphanReason": f"lease not renewed for {int(lease_age)}s",
319
+ "lastWatchedAt": lease_iso(lease_path),
320
+ "processStopResult": stop_result,
321
+ },
322
+ )
323
+ exit_code = process.wait()
324
+ break
325
+
326
+ time.sleep(1)
327
+
328
+ duration = round(time.time() - started, 3)
329
+ current = read_optional_json(status_path) or {}
330
+ if current.get("status") in {"timeout", "orphaned", "stale"}:
331
+ current["processExitCode"] = exit_code
332
+ current["processFinishedAt"] = now_iso()
333
+ current["processDurationSeconds"] = duration
334
+ write_json(status_path, current)
335
+ return 0
336
+
337
+ final = {
338
+ **base,
339
+ "startedAt": started_at,
340
+ "finishedAt": now_iso(),
341
+ "durationSeconds": duration,
342
+ "processId": process.pid,
343
+ }
344
+ if verdict is None:
345
+ final.update({"status": "success" if exit_code == 0 else "failed", "exitCode": exit_code})
346
+ else:
347
+ kind, extra = verdict
348
+ final.update({"status": kind, "exitCode": None, "processExitCode": exit_code, **extra})
349
+ write_json(status_path, final)
350
+ return 0
351
+
352
+
353
+ def main() -> int:
354
+ argv = sys.argv[1:]
355
+ if len(argv) >= 2 and argv[0] == "--worker":
356
+ return run_worker(argv[1])
357
+
358
+ timeout_seconds = DEFAULT_TIMEOUT_SECONDS
359
+ index = 0
360
+ while index < len(argv) and argv[index] != "--":
361
+ arg = argv[index]
362
+ raw = None
363
+ if arg == "--timeout":
364
+ if index + 1 >= len(argv):
365
+ print("error: --timeout needs a duration", file=sys.stderr)
366
+ return 2
367
+ raw = argv[index + 1]
368
+ index += 2
369
+ elif arg.startswith("--timeout="):
370
+ raw = arg[len("--timeout="):]
371
+ index += 1
372
+ else:
373
+ print(f"error: unknown option: {arg}", file=sys.stderr)
374
+ return 2
375
+ try:
376
+ timeout_seconds = parse_duration(raw)
377
+ except ValueError:
378
+ print(f"error: invalid duration: {raw}", file=sys.stderr)
379
+ return 2
380
+
381
+ if index >= len(argv) or argv[index] != "--" or index + 1 >= len(argv):
382
+ print(
383
+ "Usage: .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]",
384
+ file=sys.stderr,
385
+ )
386
+ return 2
387
+ if timeout_seconds <= 0:
388
+ print("error: timeout must be positive", file=sys.stderr)
389
+ return 2
390
+ if timeout_seconds > MAX_TIMEOUT_SECONDS:
391
+ print(
392
+ "error: timeout exceeds maximum 60m; split the work or get user approval",
393
+ file=sys.stderr,
394
+ )
395
+ return 2
396
+
397
+ return start_job(argv[index + 1:], timeout_seconds)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ raise SystemExit(main())