@kody-ade/kody-engine 0.4.431 → 0.4.433
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/dist/bin/kody.js +142 -91
- package/dist/runtime-services/capability-scheduler/profile.json +61 -0
- package/dist/runtime-services/capability-tick/profile.json +86 -0
- package/dist/runtime-services/capability-tick/prompt.md +77 -0
- package/dist/runtime-services/capability-tick/prompts/locked.md +50 -0
- package/dist/runtime-services/capability-tick-scripted/profile.json +76 -0
- package/dist/runtime-services/dispatch-due-loops/profile.json +54 -0
- package/dist/runtime-services/goal-manager/profile.json +56 -0
- package/dist/runtime-services/goal-scheduler/managed_todo_state.py +160 -0
- package/dist/runtime-services/goal-scheduler/profile.json +51 -0
- package/dist/runtime-services/goal-scheduler/scheduler.sh +872 -0
- package/dist/runtime-services/task-jobs/profile.json +51 -0
- package/package.json +2 -1
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# goal-scheduler: instantiate scheduled goal templates and tick active managed
|
|
4
|
+
# goal instances from the configured Kody state repo.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
local_templates_dir=".kody/goals/templates"
|
|
8
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
store_templates_dir="${KODY_GOAL_SCHEDULER_TEMPLATES_DIR:-.kody-engine/definitions/goals/templates}"
|
|
10
|
+
|
|
11
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
12
|
+
echo "[goal-scheduler] FATAL: python3 not found on PATH" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
17
|
+
echo "[goal-scheduler] FATAL: gh not found on PATH" >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
PYTHONPATH="$script_dir${PYTHONPATH:+:$PYTHONPATH}" python3 - "$local_templates_dir" "$store_templates_dir" <<'PY'
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from urllib.parse import urlparse
|
|
32
|
+
from zoneinfo import ZoneInfo
|
|
33
|
+
from managed_todo_state import (
|
|
34
|
+
is_managed_todo_text,
|
|
35
|
+
parse_todo_goal_state,
|
|
36
|
+
serialize_todo_goal_state,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
local_templates_dir = Path(sys.argv[1])
|
|
40
|
+
store_templates_dir = Path(sys.argv[2])
|
|
41
|
+
template_roots = [local_templates_dir, store_templates_dir]
|
|
42
|
+
local_todos_dir = Path(".kody/todos")
|
|
43
|
+
LOCAL_MODE = os.environ.get("KODY_GOAL_SCHEDULER_SKIP_PERSIST") == "1"
|
|
44
|
+
|
|
45
|
+
SLUG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
46
|
+
INTERVAL = re.compile(r"^(\d+)([mhdw])$")
|
|
47
|
+
MANAGED_KEYS = ("type", "destination", "capabilities", "route", "facts", "blockers")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def gh(args: list[str], input_text: str | None = None) -> str:
|
|
51
|
+
delays = gh_retry_delays()
|
|
52
|
+
for attempt in range(len(delays) + 1):
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["gh", *args],
|
|
55
|
+
input=input_text,
|
|
56
|
+
text=True,
|
|
57
|
+
stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.PIPE,
|
|
59
|
+
check=False,
|
|
60
|
+
)
|
|
61
|
+
if result.returncode == 0:
|
|
62
|
+
return result.stdout
|
|
63
|
+
message = result.stderr.strip() or result.stdout.strip() or f"gh exited {result.returncode}"
|
|
64
|
+
if attempt < len(delays) and is_rate_limit_error(message):
|
|
65
|
+
delay = delays[attempt]
|
|
66
|
+
print(
|
|
67
|
+
f"[goal-scheduler] gh rate limited; retrying in {delay:g}s "
|
|
68
|
+
f"(attempt {attempt + 2}/{len(delays) + 1})",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
if delay > 0:
|
|
72
|
+
time.sleep(delay)
|
|
73
|
+
continue
|
|
74
|
+
raise RuntimeError(message)
|
|
75
|
+
raise RuntimeError("gh retry loop exhausted")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def gh_retry_delays() -> list[float]:
|
|
79
|
+
raw = os.environ.get("KODY_GOAL_SCHEDULER_GH_RETRY_DELAYS", "1,3,10").strip()
|
|
80
|
+
if not raw:
|
|
81
|
+
return []
|
|
82
|
+
delays: list[float] = []
|
|
83
|
+
for item in raw.split(","):
|
|
84
|
+
try:
|
|
85
|
+
value = float(item.strip())
|
|
86
|
+
except ValueError:
|
|
87
|
+
continue
|
|
88
|
+
if value >= 0:
|
|
89
|
+
delays.append(value)
|
|
90
|
+
return delays
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_rate_limit_error(message: str) -> bool:
|
|
94
|
+
lower = message.lower()
|
|
95
|
+
return "api rate limit exceeded" in lower or ("rate limit" in lower and "http 403" in lower)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_not_found(err: Exception) -> bool:
|
|
99
|
+
msg = str(err)
|
|
100
|
+
return "HTTP 404" in msg or "Not Found" in msg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_config() -> dict:
|
|
104
|
+
path = Path("kody.config.json")
|
|
105
|
+
if not path.exists():
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(path.read_text())
|
|
109
|
+
return data if isinstance(data, dict) else {}
|
|
110
|
+
except Exception:
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def active_goal_config(config: dict) -> tuple[set[str], list[dict]]:
|
|
115
|
+
company = config.get("company") if isinstance(config.get("company"), dict) else {}
|
|
116
|
+
goals = company.get("activeGoals", [])
|
|
117
|
+
if not isinstance(goals, list):
|
|
118
|
+
goals = []
|
|
119
|
+
active: set[str] = set()
|
|
120
|
+
schedules: list[dict] = []
|
|
121
|
+
for item in goals:
|
|
122
|
+
if isinstance(item, str):
|
|
123
|
+
slug = item.strip()
|
|
124
|
+
if slug and SLUG.match(slug):
|
|
125
|
+
active.add(slug)
|
|
126
|
+
continue
|
|
127
|
+
if not isinstance(item, dict):
|
|
128
|
+
continue
|
|
129
|
+
template = item.get("template")
|
|
130
|
+
every = item.get("every")
|
|
131
|
+
if not isinstance(template, str) or not template.strip() or not SLUG.match(template.strip()):
|
|
132
|
+
continue
|
|
133
|
+
entry = {"template": template.strip()}
|
|
134
|
+
if isinstance(every, str) and every.strip():
|
|
135
|
+
entry["every"] = every.strip()
|
|
136
|
+
if isinstance(item.get("idPrefix"), str) and item["idPrefix"].strip():
|
|
137
|
+
entry["idPrefix"] = item["idPrefix"].strip()
|
|
138
|
+
if isinstance(item.get("facts"), dict):
|
|
139
|
+
entry["facts"] = item["facts"]
|
|
140
|
+
preferred = item.get("preferredRunTime")
|
|
141
|
+
if isinstance(preferred, dict):
|
|
142
|
+
time_value = preferred.get("time")
|
|
143
|
+
timezone_value = preferred.get("timezone")
|
|
144
|
+
if isinstance(time_value, str) and isinstance(timezone_value, str):
|
|
145
|
+
entry["preferredRunTime"] = {"time": time_value, "timezone": timezone_value}
|
|
146
|
+
schedules.append(entry)
|
|
147
|
+
return active, schedules
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def selected_goal_filter() -> set[str]:
|
|
151
|
+
raw = os.environ.get("KODY_GOAL_SCHEDULER_ONLY", "").strip()
|
|
152
|
+
if not raw:
|
|
153
|
+
return set()
|
|
154
|
+
selected: set[str] = set()
|
|
155
|
+
for item in re.split(r"[\s,]+", raw):
|
|
156
|
+
slug = item.strip()
|
|
157
|
+
if not slug:
|
|
158
|
+
continue
|
|
159
|
+
if not SLUG.match(slug):
|
|
160
|
+
raise RuntimeError(f"KODY_GOAL_SCHEDULER_ONLY contains invalid goal slug: {slug}")
|
|
161
|
+
selected.add(slug)
|
|
162
|
+
return selected
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def now_utc() -> datetime:
|
|
166
|
+
raw = os.environ.get("KODY_GOAL_SCHEDULER_NOW", "").strip()
|
|
167
|
+
if raw:
|
|
168
|
+
if raw.endswith("Z"):
|
|
169
|
+
raw = raw[:-1] + "+00:00"
|
|
170
|
+
return datetime.fromisoformat(raw).astimezone(timezone.utc)
|
|
171
|
+
return datetime.now(timezone.utc)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def interval_seconds(every: str) -> int:
|
|
175
|
+
match = INTERVAL.match(every)
|
|
176
|
+
if not match:
|
|
177
|
+
raise ValueError(f"unsupported schedule '{every}'")
|
|
178
|
+
amount = int(match.group(1))
|
|
179
|
+
unit = match.group(2)
|
|
180
|
+
return amount * {"m": 60, "h": 3600, "d": 86400, "w": 604800}[unit]
|
|
181
|
+
|
|
182
|
+
def iso_z(value: datetime) -> str:
|
|
183
|
+
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_preferred_runtime(data: dict) -> tuple[tuple[str, str, int, ZoneInfo] | None, str | None]:
|
|
187
|
+
preferred = data.get("preferredRunTime")
|
|
188
|
+
if not isinstance(preferred, dict):
|
|
189
|
+
return None, None
|
|
190
|
+
time_value = preferred.get("time")
|
|
191
|
+
timezone_value = preferred.get("timezone")
|
|
192
|
+
if not isinstance(time_value, str) or not isinstance(timezone_value, str):
|
|
193
|
+
return None, None
|
|
194
|
+
match = re.match(r"^([01]\d|2[0-3]):([0-5]\d)$", time_value)
|
|
195
|
+
if not match:
|
|
196
|
+
return None, f"invalid preferred time: {time_value}"
|
|
197
|
+
try:
|
|
198
|
+
zone = ZoneInfo(timezone_value)
|
|
199
|
+
except Exception:
|
|
200
|
+
return None, f"invalid preferred timezone: {timezone_value}"
|
|
201
|
+
preferred_minute = int(match.group(1)) * 60 + int(match.group(2))
|
|
202
|
+
return (time_value, timezone_value, preferred_minute, zone), None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def preferred_runtime_wait_reason(schedule: dict, now: datetime) -> str | None:
|
|
206
|
+
parsed, error = parse_preferred_runtime(schedule)
|
|
207
|
+
if error:
|
|
208
|
+
return error
|
|
209
|
+
if parsed is None:
|
|
210
|
+
return None
|
|
211
|
+
time_value, timezone_value, preferred_minute, zone = parsed
|
|
212
|
+
local = now.astimezone(zone)
|
|
213
|
+
current_minute = local.hour * 60 + local.minute
|
|
214
|
+
if current_minute < preferred_minute:
|
|
215
|
+
due_at = local.replace(
|
|
216
|
+
hour=preferred_minute // 60,
|
|
217
|
+
minute=preferred_minute % 60,
|
|
218
|
+
second=0,
|
|
219
|
+
microsecond=0,
|
|
220
|
+
)
|
|
221
|
+
return f"waiting preferred time {time_value} {timezone_value} until {iso_z(due_at)}"
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def bucket_suffix(every: str, now: datetime) -> str:
|
|
226
|
+
match = INTERVAL.match(every)
|
|
227
|
+
if not match:
|
|
228
|
+
raise ValueError(f"unsupported schedule '{every}'")
|
|
229
|
+
amount = int(match.group(1))
|
|
230
|
+
unit = match.group(2)
|
|
231
|
+
if amount == 1 and unit == "d":
|
|
232
|
+
return now.strftime("%Y-%m-%d")
|
|
233
|
+
if amount == 1 and unit == "w":
|
|
234
|
+
year, week, _ = now.isocalendar()
|
|
235
|
+
return f"{year}-W{week:02d}"
|
|
236
|
+
if amount == 1 and unit == "h":
|
|
237
|
+
return now.strftime("%Y-%m-%dT%H")
|
|
238
|
+
return f"b{int(now.timestamp()) // interval_seconds(every)}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def find_template(template: str) -> Path | None:
|
|
242
|
+
for root in template_roots:
|
|
243
|
+
candidate = root / template / "state.json"
|
|
244
|
+
if candidate.exists():
|
|
245
|
+
return candidate
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_template_state(template: str) -> dict | None:
|
|
250
|
+
template_path = find_template(template)
|
|
251
|
+
if template_path is None:
|
|
252
|
+
return None
|
|
253
|
+
data = json.loads(template_path.read_text())
|
|
254
|
+
return data if isinstance(data, dict) else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def normalize_state_repo(raw: object, field: str = "state.repo") -> str:
|
|
258
|
+
value = str(raw or "").strip()
|
|
259
|
+
if value.startswith(("http://", "https://")):
|
|
260
|
+
parsed = urlparse(value)
|
|
261
|
+
if parsed.scheme != "https" or parsed.netloc != "github.com":
|
|
262
|
+
raise RuntimeError(f"kody.config.json: {field} must be a GitHub repository URL")
|
|
263
|
+
value = parsed.path.strip("/").removesuffix(".git")
|
|
264
|
+
parts = value.split("/")
|
|
265
|
+
if len(parts) != 2 or not all(parts) or not all(SLUG.match(part) for part in parts):
|
|
266
|
+
raise RuntimeError(f"kody.config.json: {field} must be owner/repo or https://github.com/owner/repo")
|
|
267
|
+
return value
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def state_target(config: dict) -> tuple[str, str]:
|
|
271
|
+
if LOCAL_MODE:
|
|
272
|
+
return "__local__", ""
|
|
273
|
+
state = config.get("state") if isinstance(config.get("state"), dict) else {}
|
|
274
|
+
explicit_state_repo = state.get("repo") or config.get("stateRepo")
|
|
275
|
+
explicit_state_path = state.get("path") or config.get("statePath")
|
|
276
|
+
if explicit_state_repo and explicit_state_path:
|
|
277
|
+
return normalize_state_repo(explicit_state_repo), str(explicit_state_path).strip().strip("/")
|
|
278
|
+
|
|
279
|
+
github = config.get("github") if isinstance(config.get("github"), dict) else {}
|
|
280
|
+
owner = github.get("owner")
|
|
281
|
+
repo = github.get("repo")
|
|
282
|
+
if not isinstance(owner, str) or not owner or not isinstance(repo, str) or not repo:
|
|
283
|
+
name_with_owner = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).strip()
|
|
284
|
+
owner, repo = name_with_owner.split("/", 1)
|
|
285
|
+
state_repo = explicit_state_repo or f"{owner}/kody-state"
|
|
286
|
+
state_path = explicit_state_path or repo
|
|
287
|
+
return normalize_state_repo(state_repo), str(state_path).strip().strip("/")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def todo_file_path(state_base: str, goal_id: str) -> str:
|
|
291
|
+
prefix = f"{state_base}/" if state_base else ""
|
|
292
|
+
return f"{prefix}todos/{goal_id}.json"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def local_goal_path(goal_id: str) -> Path:
|
|
296
|
+
return local_todos_dir / f"{goal_id}.json"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def read_remote_file(state_repo: str, path: str) -> tuple[str, str] | None:
|
|
300
|
+
try:
|
|
301
|
+
meta = json.loads(gh(["api", f"/repos/{state_repo}/contents/{path}"]))
|
|
302
|
+
except Exception as err:
|
|
303
|
+
if is_not_found(err):
|
|
304
|
+
return None
|
|
305
|
+
raise
|
|
306
|
+
content = meta.get("content")
|
|
307
|
+
sha = meta.get("sha")
|
|
308
|
+
if not isinstance(content, str) or not isinstance(sha, str):
|
|
309
|
+
return None
|
|
310
|
+
return base64.b64decode(content.replace("\n", "")).decode("utf-8"), sha
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def read_remote_text(state_repo: str, path: str) -> str | None:
|
|
314
|
+
file = read_remote_file(state_repo, path)
|
|
315
|
+
return file[0] if file is not None else None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def read_remote_json(state_repo: str, path: str) -> dict | None:
|
|
319
|
+
text = read_remote_text(state_repo, path)
|
|
320
|
+
return json.loads(text) if text is not None else None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def remote_goal_exists(state_repo: str, state_base: str, goal_id: str) -> bool:
|
|
324
|
+
if LOCAL_MODE:
|
|
325
|
+
return local_goal_path(goal_id).exists()
|
|
326
|
+
return read_remote_text(state_repo, todo_file_path(state_base, goal_id)) is not None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def persist_goal(state_repo: str, state_base: str, goal_id: str, state_text: str) -> None:
|
|
330
|
+
if LOCAL_MODE:
|
|
331
|
+
target = local_goal_path(goal_id)
|
|
332
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
target.write_text(serialize_todo_goal_state(goal_id, json.loads(state_text), iso_z(now_utc())))
|
|
334
|
+
return
|
|
335
|
+
path = todo_file_path(state_base, goal_id)
|
|
336
|
+
if remote_goal_exists(state_repo, state_base, goal_id):
|
|
337
|
+
return
|
|
338
|
+
content = serialize_todo_goal_state(goal_id, json.loads(state_text), iso_z(now_utc()))
|
|
339
|
+
payload = {
|
|
340
|
+
"message": f"chore(goals): create {goal_id}",
|
|
341
|
+
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
342
|
+
}
|
|
343
|
+
try:
|
|
344
|
+
gh(["api", "--method", "PUT", f"/repos/{state_repo}/contents/{path}", "--input", "-"], json.dumps(payload))
|
|
345
|
+
except Exception as err:
|
|
346
|
+
if "HTTP 409" in str(err) or "HTTP 422" in str(err):
|
|
347
|
+
return
|
|
348
|
+
raise
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def create_instance_from_template(
|
|
352
|
+
state_repo: str,
|
|
353
|
+
state_base: str,
|
|
354
|
+
template: str,
|
|
355
|
+
goal_id: str,
|
|
356
|
+
now: datetime,
|
|
357
|
+
facts_patch: dict | None = None,
|
|
358
|
+
) -> bool:
|
|
359
|
+
if remote_goal_exists(state_repo, state_base, goal_id):
|
|
360
|
+
return False
|
|
361
|
+
template_path = find_template(template)
|
|
362
|
+
if template_path is None:
|
|
363
|
+
raise RuntimeError(f"template {template} not found")
|
|
364
|
+
data = json.loads(template_path.read_text())
|
|
365
|
+
facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
|
|
366
|
+
facts.update(facts_patch or {})
|
|
367
|
+
data["kind"] = "instance"
|
|
368
|
+
data["template"] = template
|
|
369
|
+
data["sourceTemplate"] = template
|
|
370
|
+
data["state"] = "active"
|
|
371
|
+
data["facts"] = facts
|
|
372
|
+
data.setdefault("createdAt", now.isoformat().replace("+00:00", "Z"))
|
|
373
|
+
data["updatedAt"] = now.isoformat().replace("+00:00", "Z")
|
|
374
|
+
state_text = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
|
375
|
+
persist_goal(state_repo, state_base, goal_id, state_text)
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def list_goal_ids(state_repo: str, state_base: str) -> list[str]:
|
|
380
|
+
if LOCAL_MODE:
|
|
381
|
+
if not local_todos_dir.exists():
|
|
382
|
+
return []
|
|
383
|
+
ids = []
|
|
384
|
+
for path in local_todos_dir.glob("*.json"):
|
|
385
|
+
text = path.read_text()
|
|
386
|
+
if is_managed_todo_text(text):
|
|
387
|
+
ids.append(path.stem)
|
|
388
|
+
return sorted(ids)
|
|
389
|
+
ids: set[str] = set()
|
|
390
|
+
todos_base = f"{state_base}/todos" if state_base else "todos"
|
|
391
|
+
try:
|
|
392
|
+
entries = json.loads(gh(["api", f"/repos/{state_repo}/contents/{todos_base}"]))
|
|
393
|
+
except Exception as err:
|
|
394
|
+
if not is_not_found(err):
|
|
395
|
+
raise
|
|
396
|
+
entries = []
|
|
397
|
+
if isinstance(entries, list):
|
|
398
|
+
for entry in entries:
|
|
399
|
+
if not isinstance(entry, dict):
|
|
400
|
+
continue
|
|
401
|
+
name = entry.get("name") if isinstance(entry, dict) else None
|
|
402
|
+
if entry.get("type") == "file" and isinstance(name, str) and name.endswith(".json"):
|
|
403
|
+
goal_id = name[:-5]
|
|
404
|
+
todo_text = read_remote_text(state_repo, todo_file_path(state_base, goal_id))
|
|
405
|
+
if todo_text is not None and is_managed_todo_text(todo_text):
|
|
406
|
+
ids.add(goal_id)
|
|
407
|
+
return sorted(ids)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def read_goal_state(state_repo: str, state_base: str, goal_id: str) -> dict | None:
|
|
411
|
+
if LOCAL_MODE:
|
|
412
|
+
path = local_goal_path(goal_id)
|
|
413
|
+
if not path.exists():
|
|
414
|
+
return None
|
|
415
|
+
text = path.read_text()
|
|
416
|
+
if not is_managed_todo_text(text):
|
|
417
|
+
return None
|
|
418
|
+
return resolve_template_backed_goal_state(parse_todo_goal_state(goal_id, text))
|
|
419
|
+
todo_text = read_remote_text(state_repo, todo_file_path(state_base, goal_id))
|
|
420
|
+
if todo_text is not None:
|
|
421
|
+
if not is_managed_todo_text(todo_text):
|
|
422
|
+
return None
|
|
423
|
+
return resolve_template_backed_goal_state(parse_todo_goal_state(goal_id, todo_text))
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def schedule_prefix(schedule: dict) -> str:
|
|
428
|
+
return str(schedule.get("idPrefix") or schedule["template"])
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def schedule_key(schedule: dict) -> tuple[str, str]:
|
|
432
|
+
return schedule["template"], schedule_prefix(schedule)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def goal_template(data: dict) -> str | None:
|
|
436
|
+
template = data.get("template") or data.get("sourceTemplate") or data.get("templateId")
|
|
437
|
+
return template if isinstance(template, str) else None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def resolve_template_backed_goal_state(data: dict) -> dict:
|
|
441
|
+
template = goal_template(data)
|
|
442
|
+
if not template:
|
|
443
|
+
return data
|
|
444
|
+
template_data = load_template_state(template)
|
|
445
|
+
if not template_data:
|
|
446
|
+
return data
|
|
447
|
+
merged = dict(data)
|
|
448
|
+
for key in (
|
|
449
|
+
"type",
|
|
450
|
+
"destination",
|
|
451
|
+
"capabilities",
|
|
452
|
+
"route",
|
|
453
|
+
"schedule",
|
|
454
|
+
"scheduleMode",
|
|
455
|
+
"loopTarget",
|
|
456
|
+
"preferredRunTime",
|
|
457
|
+
"saveReport",
|
|
458
|
+
):
|
|
459
|
+
if key in template_data:
|
|
460
|
+
merged[key] = template_data[key]
|
|
461
|
+
elif key in merged and key in (
|
|
462
|
+
"schedule",
|
|
463
|
+
"loopTarget",
|
|
464
|
+
"preferredRunTime",
|
|
465
|
+
"saveReport",
|
|
466
|
+
):
|
|
467
|
+
del merged[key]
|
|
468
|
+
template_facts = template_data.get("facts") if isinstance(template_data.get("facts"), dict) else {}
|
|
469
|
+
runtime_facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
|
|
470
|
+
merged["facts"] = {**template_facts, **runtime_facts}
|
|
471
|
+
merged["state"] = data.get("state", "active")
|
|
472
|
+
return merged
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def is_managed_goal(data: dict) -> bool:
|
|
476
|
+
return all(key in data for key in MANAGED_KEYS)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def is_scheduled_instance(goal_id: str, data: dict, schedule: dict) -> bool:
|
|
480
|
+
template = goal_template(data)
|
|
481
|
+
prefix = schedule_prefix(schedule)
|
|
482
|
+
return template == schedule["template"] and goal_id.startswith(f"{prefix}-")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def parse_state_time(value: object) -> float:
|
|
486
|
+
if not isinstance(value, str) or not value:
|
|
487
|
+
return 0
|
|
488
|
+
try:
|
|
489
|
+
raw = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
490
|
+
return datetime.fromisoformat(raw).timestamp()
|
|
491
|
+
except Exception:
|
|
492
|
+
return 0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def scheduled_instance_sort_key(goal_id: str) -> tuple[float, str]:
|
|
496
|
+
data = goal_state_cache.get(goal_id)
|
|
497
|
+
if not isinstance(data, dict):
|
|
498
|
+
return 0, goal_id
|
|
499
|
+
return parse_state_time(data.get("createdAt") or data.get("updatedAt")), goal_id
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def goal_schedule_interval(data: dict) -> str | None:
|
|
503
|
+
schedule = data.get("schedule")
|
|
504
|
+
if not isinstance(schedule, str):
|
|
505
|
+
return None
|
|
506
|
+
every = schedule.strip()
|
|
507
|
+
return every if INTERVAL.match(every) else None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def last_goal_tick_time(data: dict) -> float:
|
|
511
|
+
schedule_state = data.get("scheduleState")
|
|
512
|
+
if not isinstance(schedule_state, dict):
|
|
513
|
+
return 0
|
|
514
|
+
last_goal_tick_at = schedule_state.get("lastGoalTickAt")
|
|
515
|
+
last_decision = schedule_state.get("lastDecision")
|
|
516
|
+
if isinstance(last_goal_tick_at, str):
|
|
517
|
+
return parse_state_time(last_goal_tick_at)
|
|
518
|
+
if isinstance(last_decision, dict):
|
|
519
|
+
return parse_state_time(last_decision.get("at"))
|
|
520
|
+
return 0
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def last_goal_dispatch_time(data: dict) -> float:
|
|
524
|
+
schedule_state = data.get("scheduleState")
|
|
525
|
+
if not isinstance(schedule_state, dict):
|
|
526
|
+
return 0
|
|
527
|
+
last_decision = schedule_state.get("lastDecision")
|
|
528
|
+
if not isinstance(last_decision, dict) or last_decision.get("kind") != "dispatch":
|
|
529
|
+
return 0
|
|
530
|
+
return parse_state_time(last_decision.get("at"))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def preferred_daily_wait_reason(data: dict, now: datetime) -> str | None:
|
|
534
|
+
parsed, error = parse_preferred_runtime(data)
|
|
535
|
+
if error:
|
|
536
|
+
return error
|
|
537
|
+
if parsed is None:
|
|
538
|
+
return None
|
|
539
|
+
time_value, timezone_value, preferred_minute, zone = parsed
|
|
540
|
+
local = now.astimezone(zone)
|
|
541
|
+
preferred_today = local.replace(
|
|
542
|
+
hour=preferred_minute // 60,
|
|
543
|
+
minute=preferred_minute % 60,
|
|
544
|
+
second=0,
|
|
545
|
+
microsecond=0,
|
|
546
|
+
)
|
|
547
|
+
current_minute = local.hour * 60 + local.minute
|
|
548
|
+
if current_minute < preferred_minute:
|
|
549
|
+
return f"waiting preferred time {time_value} {timezone_value} until {iso_z(preferred_today)}"
|
|
550
|
+
|
|
551
|
+
last_dispatch = last_goal_dispatch_time(data)
|
|
552
|
+
if last_dispatch > 0:
|
|
553
|
+
dispatched_local = datetime.fromtimestamp(last_dispatch, timezone.utc).astimezone(zone)
|
|
554
|
+
if dispatched_local.date() == local.date():
|
|
555
|
+
next_due = preferred_today + timedelta(days=1)
|
|
556
|
+
return (
|
|
557
|
+
f"already dispatched today at preferred time {time_value} {timezone_value}; "
|
|
558
|
+
f"next eligible {iso_z(next_due)}"
|
|
559
|
+
)
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def schedule_wait_reason(data: dict, now: datetime, activation_every: str | None = None) -> str | None:
|
|
564
|
+
schedule_state = data.get("scheduleState")
|
|
565
|
+
lease = schedule_state.get("lease") if isinstance(schedule_state, dict) else None
|
|
566
|
+
lease_expires = parse_state_time(lease.get("expiresAt")) if isinstance(lease, dict) else 0
|
|
567
|
+
if lease_expires > now.timestamp():
|
|
568
|
+
return f"leased until {iso_z(datetime.fromtimestamp(lease_expires, timezone.utc))}"
|
|
569
|
+
every = activation_every or goal_schedule_interval(data)
|
|
570
|
+
if not every:
|
|
571
|
+
return None
|
|
572
|
+
if every == "1d" and isinstance(data.get("preferredRunTime"), dict):
|
|
573
|
+
return preferred_daily_wait_reason(data, now)
|
|
574
|
+
last_tick = last_goal_tick_time(data)
|
|
575
|
+
if last_tick <= 0:
|
|
576
|
+
return None
|
|
577
|
+
next_tick = last_tick + interval_seconds(every)
|
|
578
|
+
if now.timestamp() >= next_tick:
|
|
579
|
+
return None
|
|
580
|
+
due_at = iso_z(datetime.fromtimestamp(next_tick, timezone.utc))
|
|
581
|
+
return f"waiting schedule {every} until {due_at}"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def write_existing_goal_state(
|
|
585
|
+
state_repo: str,
|
|
586
|
+
state_base: str,
|
|
587
|
+
goal_id: str,
|
|
588
|
+
data: dict,
|
|
589
|
+
message: str,
|
|
590
|
+
sha: str | None,
|
|
591
|
+
) -> None:
|
|
592
|
+
content = serialize_todo_goal_state(goal_id, data, iso_z(now_utc()))
|
|
593
|
+
if LOCAL_MODE:
|
|
594
|
+
local_goal_path(goal_id).write_text(content)
|
|
595
|
+
return
|
|
596
|
+
if not sha:
|
|
597
|
+
raise RuntimeError(f"cannot update {goal_id}: missing state sha")
|
|
598
|
+
payload = {
|
|
599
|
+
"message": message,
|
|
600
|
+
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
601
|
+
"sha": sha,
|
|
602
|
+
}
|
|
603
|
+
gh(
|
|
604
|
+
["api", "--method", "PUT", f"/repos/{state_repo}/contents/{todo_file_path(state_base, goal_id)}", "--input", "-"],
|
|
605
|
+
json.dumps(payload),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def read_fresh_goal_for_update(state_repo: str, state_base: str, goal_id: str) -> tuple[dict, str | None] | None:
|
|
610
|
+
if LOCAL_MODE:
|
|
611
|
+
path = local_goal_path(goal_id)
|
|
612
|
+
if not path.exists():
|
|
613
|
+
return None
|
|
614
|
+
text = path.read_text()
|
|
615
|
+
if not is_managed_todo_text(text):
|
|
616
|
+
return None
|
|
617
|
+
return parse_todo_goal_state(goal_id, text), None
|
|
618
|
+
file = read_remote_file(state_repo, todo_file_path(state_base, goal_id))
|
|
619
|
+
if file is None or not is_managed_todo_text(file[0]):
|
|
620
|
+
return None
|
|
621
|
+
return parse_todo_goal_state(goal_id, file[0]), file[1]
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def reserve_goal_tick(
|
|
625
|
+
state_repo: str,
|
|
626
|
+
state_base: str,
|
|
627
|
+
goal_id: str,
|
|
628
|
+
now: datetime,
|
|
629
|
+
activation_every: str | None,
|
|
630
|
+
) -> tuple[str | None, str | None]:
|
|
631
|
+
fresh = read_fresh_goal_for_update(state_repo, state_base, goal_id)
|
|
632
|
+
if fresh is None:
|
|
633
|
+
return None, "goal state disappeared"
|
|
634
|
+
raw_data, sha = fresh
|
|
635
|
+
data = resolve_template_backed_goal_state(raw_data)
|
|
636
|
+
if data.get("state") != "active" or not is_managed_goal(data):
|
|
637
|
+
return None, "goal is no longer active"
|
|
638
|
+
wait_reason = schedule_wait_reason(data, now, activation_every)
|
|
639
|
+
if wait_reason:
|
|
640
|
+
return None, wait_reason
|
|
641
|
+
|
|
642
|
+
lease_id = f"{os.getpid()}-{time.time_ns()}"
|
|
643
|
+
acquired_at = iso_z(now)
|
|
644
|
+
schedule_state = dict(raw_data.get("scheduleState")) if isinstance(raw_data.get("scheduleState"), dict) else {}
|
|
645
|
+
schedule_state["lease"] = {
|
|
646
|
+
"id": lease_id,
|
|
647
|
+
"acquiredAt": acquired_at,
|
|
648
|
+
"expiresAt": iso_z(now + timedelta(hours=1)),
|
|
649
|
+
}
|
|
650
|
+
raw_data["scheduleState"] = schedule_state
|
|
651
|
+
try:
|
|
652
|
+
write_existing_goal_state(
|
|
653
|
+
state_repo,
|
|
654
|
+
state_base,
|
|
655
|
+
goal_id,
|
|
656
|
+
raw_data,
|
|
657
|
+
f"chore(loops): reserve {goal_id}",
|
|
658
|
+
sha,
|
|
659
|
+
)
|
|
660
|
+
except Exception as err:
|
|
661
|
+
if "HTTP 409" in str(err) or "HTTP 422" in str(err):
|
|
662
|
+
return None, "claimed by another scheduler"
|
|
663
|
+
raise
|
|
664
|
+
return lease_id, None
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def finish_goal_tick_reservation(
|
|
668
|
+
state_repo: str,
|
|
669
|
+
state_base: str,
|
|
670
|
+
goal_id: str,
|
|
671
|
+
lease_id: str,
|
|
672
|
+
reserved_at: datetime,
|
|
673
|
+
) -> None:
|
|
674
|
+
try:
|
|
675
|
+
fresh = read_fresh_goal_for_update(state_repo, state_base, goal_id)
|
|
676
|
+
if fresh is None:
|
|
677
|
+
return
|
|
678
|
+
raw_data, sha = fresh
|
|
679
|
+
schedule_state = raw_data.get("scheduleState")
|
|
680
|
+
if not isinstance(schedule_state, dict):
|
|
681
|
+
return
|
|
682
|
+
lease = schedule_state.get("lease")
|
|
683
|
+
if not isinstance(lease, dict) or lease.get("id") != lease_id:
|
|
684
|
+
return
|
|
685
|
+
next_schedule_state = dict(schedule_state)
|
|
686
|
+
next_schedule_state.pop("lease", None)
|
|
687
|
+
at = iso_z(reserved_at)
|
|
688
|
+
next_schedule_state["lastGoalTickAt"] = at
|
|
689
|
+
next_schedule_state["lastDecision"] = {
|
|
690
|
+
"kind": "idle",
|
|
691
|
+
"reason": "scheduler dispatch completed without a newer Loop decision",
|
|
692
|
+
"at": at,
|
|
693
|
+
}
|
|
694
|
+
raw_data["scheduleState"] = next_schedule_state
|
|
695
|
+
write_existing_goal_state(
|
|
696
|
+
state_repo,
|
|
697
|
+
state_base,
|
|
698
|
+
goal_id,
|
|
699
|
+
raw_data,
|
|
700
|
+
f"chore(loops): finish reservation {goal_id}",
|
|
701
|
+
sha,
|
|
702
|
+
)
|
|
703
|
+
except Exception as err:
|
|
704
|
+
if "HTTP 409" not in str(err) and "HTTP 422" not in str(err):
|
|
705
|
+
print(f"[goal-scheduler] warning: failed to finish reservation for {goal_id} ({err})")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
config = load_config()
|
|
709
|
+
active, schedules = active_goal_config(config)
|
|
710
|
+
only_goals = selected_goal_filter()
|
|
711
|
+
if only_goals:
|
|
712
|
+
active = {goal_id for goal_id in active if goal_id in only_goals}
|
|
713
|
+
schedules = [schedule for schedule in schedules if schedule["template"] in only_goals]
|
|
714
|
+
state_repo, state_base = state_target(config)
|
|
715
|
+
now = now_utc()
|
|
716
|
+
created: list[str] = []
|
|
717
|
+
errors: list[str] = []
|
|
718
|
+
goal_ids = list_goal_ids(state_repo, state_base)
|
|
719
|
+
if not active and not schedules and not goal_ids:
|
|
720
|
+
if only_goals:
|
|
721
|
+
print(
|
|
722
|
+
"[goal-scheduler] no selected active goals or persisted Loops after "
|
|
723
|
+
f"KODY_GOAL_SCHEDULER_ONLY={','.join(sorted(only_goals))}"
|
|
724
|
+
)
|
|
725
|
+
else:
|
|
726
|
+
print("[goal-scheduler] no company.activeGoals configured and no persisted Loops")
|
|
727
|
+
print("KODY_SKIP_AGENT=true")
|
|
728
|
+
raise SystemExit(0)
|
|
729
|
+
goal_state_cache: dict[str, dict | None] = {}
|
|
730
|
+
scheduled_recurring = [schedule for schedule in schedules if schedule.get("every")]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def cached_goal_state(goal_id: str) -> dict | None:
|
|
734
|
+
if goal_id not in goal_state_cache:
|
|
735
|
+
goal_state_cache[goal_id] = read_goal_state(state_repo, state_base, goal_id)
|
|
736
|
+
return goal_state_cache[goal_id]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
scheduled_active: dict[tuple[str, str], set[str]] = {}
|
|
740
|
+
for goal_id in goal_ids:
|
|
741
|
+
try:
|
|
742
|
+
data = cached_goal_state(goal_id)
|
|
743
|
+
except Exception:
|
|
744
|
+
continue
|
|
745
|
+
if not isinstance(data, dict) or data.get("state") != "active" or not is_managed_goal(data):
|
|
746
|
+
continue
|
|
747
|
+
for schedule in scheduled_recurring:
|
|
748
|
+
if is_scheduled_instance(goal_id, data, schedule):
|
|
749
|
+
scheduled_active.setdefault(schedule_key(schedule), set()).add(goal_id)
|
|
750
|
+
scheduled_selected = {
|
|
751
|
+
key: {sorted(ids, key=scheduled_instance_sort_key)[0]} for key, ids in scheduled_active.items() if ids
|
|
752
|
+
}
|
|
753
|
+
selected_scheduled_ids = {goal_id for ids in scheduled_selected.values() for goal_id in ids}
|
|
754
|
+
|
|
755
|
+
for goal_id in sorted(active):
|
|
756
|
+
try:
|
|
757
|
+
if create_instance_from_template(state_repo, state_base, goal_id, goal_id, now):
|
|
758
|
+
created.append(goal_id)
|
|
759
|
+
except Exception as err:
|
|
760
|
+
errors.append(f"{goal_id}: {err}")
|
|
761
|
+
|
|
762
|
+
for schedule in schedules:
|
|
763
|
+
every = schedule.get("every")
|
|
764
|
+
if not every:
|
|
765
|
+
active.add(schedule["template"])
|
|
766
|
+
continue
|
|
767
|
+
try:
|
|
768
|
+
suffix = bucket_suffix(every, now)
|
|
769
|
+
prefix = schedule_prefix(schedule)
|
|
770
|
+
running = scheduled_selected.get(schedule_key(schedule), set())
|
|
771
|
+
wait_reason = preferred_runtime_wait_reason(schedule, now)
|
|
772
|
+
if wait_reason:
|
|
773
|
+
if running:
|
|
774
|
+
active.update(running)
|
|
775
|
+
print(f"[goal-scheduler] skip {schedule['template']}: {wait_reason}")
|
|
776
|
+
continue
|
|
777
|
+
if running:
|
|
778
|
+
active.update(running)
|
|
779
|
+
print(
|
|
780
|
+
f"[goal-scheduler] skip {schedule['template']}: "
|
|
781
|
+
f"active scheduled instance already running ({', '.join(sorted(running))})"
|
|
782
|
+
)
|
|
783
|
+
continue
|
|
784
|
+
goal_id = f"{prefix}-{suffix}"
|
|
785
|
+
active.add(goal_id)
|
|
786
|
+
facts = schedule.get("facts") if isinstance(schedule.get("facts"), dict) else {}
|
|
787
|
+
if create_instance_from_template(state_repo, state_base, schedule["template"], goal_id, now, facts):
|
|
788
|
+
created.append(goal_id)
|
|
789
|
+
except Exception as err:
|
|
790
|
+
errors.append(f"{schedule['template']}: {err}")
|
|
791
|
+
|
|
792
|
+
for goal_id in created:
|
|
793
|
+
print(f"[goal-scheduler] created goal instance {goal_id}")
|
|
794
|
+
for error in errors:
|
|
795
|
+
print(f"[goal-scheduler] schedule skipped: {error}")
|
|
796
|
+
|
|
797
|
+
goal_ids = sorted(set(goal_ids) | set(created))
|
|
798
|
+
if not goal_ids:
|
|
799
|
+
print("[goal-scheduler] no goal instances yet")
|
|
800
|
+
print("KODY_SKIP_AGENT=true")
|
|
801
|
+
raise SystemExit(0)
|
|
802
|
+
|
|
803
|
+
active_count = 0
|
|
804
|
+
managed_active = 0
|
|
805
|
+
for goal_id in goal_ids:
|
|
806
|
+
try:
|
|
807
|
+
data = cached_goal_state(goal_id)
|
|
808
|
+
except Exception as err:
|
|
809
|
+
print(f"[goal-scheduler] skip {goal_id}: failed to read state ({err})")
|
|
810
|
+
continue
|
|
811
|
+
if not isinstance(data, dict):
|
|
812
|
+
continue
|
|
813
|
+
template = goal_template(data)
|
|
814
|
+
direct_loop = (
|
|
815
|
+
data.get("scheduleMode") == "agentLoop"
|
|
816
|
+
and template is None
|
|
817
|
+
and goal_schedule_interval(data) is not None
|
|
818
|
+
)
|
|
819
|
+
selected = (
|
|
820
|
+
not only_goals
|
|
821
|
+
or goal_id in only_goals
|
|
822
|
+
or (isinstance(template, str) and template in only_goals)
|
|
823
|
+
)
|
|
824
|
+
activated = (
|
|
825
|
+
selected
|
|
826
|
+
and (
|
|
827
|
+
direct_loop
|
|
828
|
+
or goal_id in active
|
|
829
|
+
or (isinstance(template, str) and template in active)
|
|
830
|
+
or goal_id in selected_scheduled_ids
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
if not activated or data.get("state") != "active":
|
|
834
|
+
continue
|
|
835
|
+
active_count += 1
|
|
836
|
+
managed = is_managed_goal(data)
|
|
837
|
+
if not managed:
|
|
838
|
+
print(f"[goal-scheduler] skip {goal_id}: todo file is not a managed goal")
|
|
839
|
+
continue
|
|
840
|
+
activation_schedule = next(
|
|
841
|
+
(
|
|
842
|
+
schedule
|
|
843
|
+
for schedule in scheduled_recurring
|
|
844
|
+
if is_scheduled_instance(goal_id, data, schedule)
|
|
845
|
+
),
|
|
846
|
+
None,
|
|
847
|
+
)
|
|
848
|
+
activation_every = activation_schedule.get("every") if activation_schedule else None
|
|
849
|
+
wait_reason = schedule_wait_reason(data, now, activation_every)
|
|
850
|
+
if wait_reason:
|
|
851
|
+
print(f"[goal-scheduler] skip {goal_id}: {wait_reason}")
|
|
852
|
+
continue
|
|
853
|
+
lease_id, lease_wait_reason = reserve_goal_tick(
|
|
854
|
+
state_repo,
|
|
855
|
+
state_base,
|
|
856
|
+
goal_id,
|
|
857
|
+
now,
|
|
858
|
+
activation_every,
|
|
859
|
+
)
|
|
860
|
+
if not lease_id:
|
|
861
|
+
print(f"[goal-scheduler] skip {goal_id}: {lease_wait_reason or 'not reserved'}")
|
|
862
|
+
continue
|
|
863
|
+
managed_active += 1
|
|
864
|
+
print(f"[goal-scheduler] -> tick {goal_id} (goal-manager)")
|
|
865
|
+
result = subprocess.run(["kody-engine", "implementation", "goal-manager", "--goal", goal_id], check=False)
|
|
866
|
+
finish_goal_tick_reservation(state_repo, state_base, goal_id, lease_id, now)
|
|
867
|
+
if result.returncode != 0:
|
|
868
|
+
print(f"[goal-scheduler] tick {goal_id} failed (continuing)")
|
|
869
|
+
|
|
870
|
+
print(f"[goal-scheduler] scanned {len(goal_ids)} goal instance(s), active={active_count}, managed={managed_active}")
|
|
871
|
+
print("KODY_SKIP_AGENT=true")
|
|
872
|
+
PY
|