@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.
@@ -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