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.
- package/README.md +47 -13
- package/dist/backend/api/claude-hook-routes.js +7 -1
- package/dist/backend/gateway/gateway-command-parser.js +4 -0
- package/dist/backend/gateway/gateway-service.js +263 -32
- package/dist/backend/gateway/gateway-settings-service.js +34 -0
- package/dist/backend/server.js +3 -1
- package/dist/backend/services/claude-hook-service.js +33 -3
- package/dist/backend/services/harness-service.js +37 -32
- package/dist/backend/services/job-guard-service.js +126 -0
- package/dist/backend/services/message-service.js +1 -1
- package/dist/backend/templates/harness/architect-agent.js +19 -8
- package/dist/backend/templates/harness/claude-root.js +7 -11
- package/dist/backend/templates/harness/coder-agent.js +45 -17
- package/dist/backend/templates/harness/gitignore.js +3 -2
- package/dist/backend/templates/harness/project-manager-agent.js +16 -11
- package/dist/backend/templates/harness/reviewer-agent.js +25 -28
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +42 -31
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +37 -18
- package/dist-frontend/assets/index-DC9-SB7F.css +32 -0
- package/dist-frontend/assets/{index-DHuS-DYr.js → index-DDrcJLOG.js} +37 -37
- package/dist-frontend/index.html +2 -2
- package/docs/full-harness-baseline.md +7 -5
- package/docs/gateway-design.md +40 -6
- package/docs/product-design.md +11 -2
- package/docs/vcm-cc-best-practices.md +11 -4
- package/package.json +1 -1
- package/scripts/harness-tools/run-long-check +401 -0
- package/scripts/harness-tools/vcm-bash-guard +107 -0
- package/scripts/harness-tools/watch-job +204 -0
- package/scripts/install-vcm-harness.mjs +93 -387
- package/scripts/uninstall-vcm-harness.mjs +18 -1
- package/scripts/verify-package.mjs +3 -0
- package/dist/backend/templates/harness/known-issues-doc.js +0 -22
- package/dist-frontend/assets/index-7lq6YPCq.css +0 -32
package/dist-frontend/index.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
|
100
|
-
| Runtime tool | `.ai/tools/watch-job` | whole-file | Conditional long-term |
|
|
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
|
|
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
|
|
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
|
package/docs/gateway-design.md
CHANGED
|
@@ -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
|
|
90
|
-
project or task is selected, gateway replies with a
|
|
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`, `/
|
|
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.
|
package/docs/product-design.md
CHANGED
|
@@ -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
|
|
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
|
|
284
|
-
`.ai/tools/
|
|
285
|
-
`
|
|
286
|
-
|
|
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
|
@@ -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())
|