fsanyoto commited on
Commit
5337d43
Β·
verified Β·
1 Parent(s): b4dbdad

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/automation_engine.py CHANGED
@@ -2469,11 +2469,19 @@ def retire_automation_board_state(rt, tables=None):
2469
  #: ⭐⭐ WAVE 31 Β· T35 (D-134) β€” the definitions memo, and the two things that make it safe.
2470
  #: `tenant -> (monotonic_at, defs)`. Read ONLY through `all_definitions(..., cached=True)`, which
2471
  #: exactly one caller uses (`grid_hook`).
2472
- _DEFS_MEMO = {}
2473
  #: Deliberately SHORT. This exists to collapse one burst of row events into one read, not to be a
2474
  #: cache β€” an import of 20,000 rows arrives in far less than this, and a stale trigger for two
2475
  #: seconds is bounded and recoverable where a stale one for a minute is a mystery.
2476
- _DEFS_TTL = 2.0
 
 
 
 
 
 
 
 
2477
 
2478
 
2479
  def _store_update(rt, fn, flush="sync", keeps_defs=False):
@@ -2501,12 +2509,14 @@ def _store_update(rt, fn, flush="sync", keeps_defs=False):
2501
  trigger set for another tenant. The memo holds at most a handful of entries and the correct,
2502
  boring thing costs nothing.
2503
  """
2504
- if not keeps_defs:
2505
- _DEFS_MEMO.clear()
2506
- return rt.update(STORE_KEY, fn, flush=flush)
 
 
2507
 
2508
 
2509
- def all_definitions(rt, cached=False):
2510
  """Every automation definition for this tenant.
2511
 
2512
  ⭐⭐ `cached=True` IS D-134's FIX, AND IT IS OPT-IN FOR A REASON. `grid_hook` runs ONCE PER ROW
@@ -2536,7 +2546,33 @@ def all_definitions(rt, cached=False):
2536
  out = {str(aid): _without_retired_board(defn)[0] for aid, defn in raw.items()}
2537
  if cached:
2538
  _DEFS_MEMO[str(getattr(rt, "key", "") or "")] = (time.monotonic(), out)
2539
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2540
 
2541
 
2542
  def _new_id(existing):
@@ -3086,9 +3122,13 @@ def _no_step(_text):
3086
  is the part a test is allowed to call on its own."""
3087
 
3088
 
3089
- def _release(tenant, auto_id):
3090
- with _RUN_LOCK:
3091
- _RUNNING.pop((tenant, str(auto_id)), None)
 
 
 
 
3092
 
3093
 
3094
  def _commit_run(rt, auto_id, state, summary, counts, ok, affected=None, steps=None, notes=None):
@@ -12626,11 +12666,13 @@ def _table_handle(row, url_field):
12626
  or "").strip().lower()
12627
 
12628
 
12629
- def compute_metric_cells(rt, table_key, today=None):
12630
  """Recompute every metric cell on ONE table from the master series. One coalesced write,
12631
  only when something actually changed (the flush-ceiling law); zero reads when the table
12632
  has no metric fields or the master is off. Returns the number of rows touched."""
12633
- t = ut_get(rt, table_key)
 
 
12634
  mfields = metric_fields_of_table(t)
12635
  if not mfields:
12636
  return 0
@@ -12664,8 +12706,13 @@ def compute_metric_cells(rt, table_key, today=None):
12664
  tt.setdefault("rows", {}).setdefault(rid, {}).update(vals)
12665
  return cur
12666
 
12667
- rt.update(UT_STORE_KEY, _up, flush="sync")
12668
- return len(changes)
 
 
 
 
 
12669
 
12670
 
12671
  # ── ⭐⭐ THE RELATIONAL PASS (2026-08-07) β€” derived LINK cells and ROLLUP cells ────────────────
@@ -13204,14 +13251,28 @@ def _refresh_relations_inplace(tables, log=print):
13204
  return touched
13205
 
13206
 
13207
- def refresh_relations(rt, log=print):
13208
  """The tick half of the relational pass β€” the twin of `refresh_metrics`.
13209
 
13210
  ⚠ Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale
13211
  when table B gains a row, and A has no way to know that happened. Cheap by construction β€” a
13212
  table declaring neither kind costs one dict scan.
13213
  """
13214
- snapshot = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13215
  str(key): {**(table or {}),
13216
  "rows": {str(rid): dict(row or {})
13217
  for rid, row in ((table or {}).get("rows") or {}).items()}}
@@ -13244,15 +13305,17 @@ def refresh_relations(rt, log=print):
13244
  return actual[0]
13245
 
13246
 
13247
- def refresh_metrics(rt, today=None, log=print):
13248
  """The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window
13249
  metric can never go staler than one tick while a scheduler exists. Cheap by construction β€”
13250
  a table without metric fields costs a dict scan and nothing else."""
13251
- touched = 0
13252
- for tk, t in ut_all(rt).items():
13253
- if metric_fields_of_table(t):
13254
- try:
13255
- touched += compute_metric_cells(rt, tk, today=today)
 
 
13256
  except Exception as e: # noqa: BLE001
13257
  log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}")
13258
  return touched
@@ -14441,7 +14504,7 @@ def run_async(rt, tenant, auto_id, username="automation", log=print, rows=None):
14441
  # THE TICK + the in-process scheduler
14442
  # ---------------------------------------------------------------------------------------------
14443
 
14444
- def pending_collect_ids(rt):
14445
  """⭐ 2026-08-06 β€” automations holding a snapshot the vendor is still building.
14446
 
14447
  β›” THE DEFECT THIS CLOSES, reported live: *"why is the SMALL 10 records test search taking
@@ -14458,7 +14521,8 @@ def pending_collect_ids(rt):
14458
  make an unscheduled automation's paid result depend on someone remembering to press a button.
14459
  """
14460
  out = []
14461
- for aid, d in all_definitions(rt).items():
 
14462
  if not isinstance(d, dict):
14463
  continue
14464
  if (d.get("trigger") or {}).get("paused"):
@@ -14487,81 +14551,240 @@ def pending_collect_ids(rt):
14487
  return sorted(out)
14488
 
14489
 
14490
- def due_ids(rt, now=None):
14491
- """Which of this tenant's automations a tick at `now` should start. Pure over the store."""
14492
- return sorted(aid for aid, d in all_definitions(rt).items() if is_due(d, now))
 
14493
 
14494
 
14495
- def tick(rt, tenant, now=None, log=print):
14496
  """Fire every due automation for ONE tenant + poll every email trigger (C3). Returns the
14497
  ids started. The email polls are bounded and fail-quiet per automation β€” one broken
14498
  mailbox connection must not stop the tenant's schedules."""
14499
- started = []
 
14500
  # ⭐ COLLECT-FIRST (2026-08-06). A snapshot the vendor has finished building is a result the
14501
  # tenant has already been charged for; it is collected whether or not this automation is on a
14502
  # schedule. `_claim` makes the union safe β€” an id in both lists starts once.
14503
- for aid in dict.fromkeys(list(pending_collect_ids(rt)) + list(due_ids(rt, now))):
 
14504
  if run_async(rt, tenant, aid, username="scheduler", log=log):
14505
  started.append(aid)
14506
- for aid, d in all_definitions(rt).items():
14507
  if (d.get("trigger") or {}).get("key") == "email":
14508
  try:
14509
  if email_poll(rt, tenant, aid, d, log=log) is not None:
14510
  started.append(aid)
14511
  except Exception as e: # noqa: BLE001
14512
  log(f"[aios-auto] email poll {tenant}/{aid} failed: {type(e).__name__}: {e}")
14513
- # C7's amendment: date-window metrics advance with the tick, so `today` is never staler
14514
- # than one tick while a scheduler exists. A tenant without metric fields pays a dict scan.
14515
- try:
14516
- refresh_metrics(rt, log=log)
14517
- except Exception as e: # noqa: BLE001
14518
- log(f"[aios-auto] metric refresh {tenant} failed: {type(e).__name__}: {e}")
14519
- # ⭐ 2026-08-07 β€” the relational pass, on the same tick and for the same reason. A rollup on
14520
- # table A goes stale when table B gains a row, and A cannot know that happened; the tick is
14521
- # the only place that sees both. ⚠ SEPARATE try/except from the metrics above deliberately β€”
14522
- # one pass failing must not silently cancel the other, which a shared block would do.
14523
- try:
14524
- refresh_relations(rt, log=log)
14525
- except Exception as e: # noqa: BLE001
14526
- log(f"[aios-auto] relation refresh {tenant} failed: {type(e).__name__}: {e}")
14527
- # ⭐ THE READ-THROUGH ROLLUPS, on the same tick and for a stronger version of the same reason.
14528
- # A linked rollup goes stale when the LINKED TABLE gains a row; a source-backed one goes stale
14529
- # when ODOO does β€” and its window moves on its own besides (a `ytd` column is wrong on 1
14530
- # January without anybody writing anything). The tick is the only place that sees either.
14531
- #
14532
- # β›” ITS OWN try/except, like the two passes above, and the reason is the same one stated
14533
- # there: this pass REFUSES loudly on a truncated group set, and a shared block would let that
14534
- # honest refusal silently cancel the relational pass that had already succeeded.
14535
- #
14536
- # ⚠ IT WRITES NOTHING WHEN IT REFUSES β€” see `rollup_sql`'s header. A half-applied rollup mixes
14537
- # two vintages of one column and looks completely normal, which is why the refusal is total.
14538
- try:
14539
- import rollup_sql
14540
- for _tk in list((rt.get(UT_STORE_KEY) or {})):
14541
- if rollup_sql.source_fields((rt.get(UT_STORE_KEY) or {}).get(_tk) or {}):
14542
- # ⚠ `today` is DEFAULTED INSIDE `compute`, not passed: this scope has no
14543
- # such local, and `refresh_metrics` above resolves it the same way.
14544
- n = rollup_sql.compute(rt, _tk)
14545
- if n:
14546
- log(f"[aios-auto] source rollups {tenant}/{_tk}: {n}")
14547
- except Exception as e: # noqa: BLE001
14548
- log(f"[aios-auto] source rollup {tenant} failed: {type(e).__name__}: {e}")
14549
  if started:
14550
  log(f"[aios-auto] tick {tenant}: started {', '.join(started)}")
14551
- return started
14552
-
14553
-
14554
- def tick_all(now=None, log=print):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14555
  """Every registered tenant. Fail-quiet per tenant: one tenant's broken store must not stop
14556
  the others' schedules."""
14557
- from harness import runtime as _rt
14558
- out = {}
14559
- for slug in _rt.known_tenants():
14560
- try:
14561
- out[slug] = tick(_rt.get_runtime(slug), slug, now=now, log=log)
14562
- except Exception as e: # noqa: BLE001
14563
- log(f"[aios-auto] tick {slug} skipped: {type(e).__name__}: {e}")
14564
- return out
 
 
 
 
 
 
 
 
 
14565
 
14566
 
14567
  #: How often the in-process scheduler wakes. A minute is the cron resolution; anything finer
@@ -14585,7 +14808,7 @@ def scheduler_loop(log=print):
14585
  log(f"[aios-auto] scheduler tick failed: {type(e).__name__}: {e}")
14586
 
14587
 
14588
- def start_scheduler(log=print):
14589
  """Start the loop once per process. **OPT-IN: `AIOS_AUTOMATIONS=1`.**
14590
 
14591
  ⚠ AMENDED 2026-08-03 (D), and the reason is a measurement rather than a preference. The wave
@@ -14602,9 +14825,15 @@ def start_scheduler(log=print):
14602
  external EventBridge tick (R5) does not depend on it either way, since that POSTs the
14603
  endpoint rather than riding this thread.
14604
  """
14605
- if os.environ.get("AIOS_AUTOMATIONS") != "1":
14606
- return False
14607
- if _SCHEDULER[0] is not None:
 
 
 
 
 
 
14608
  return False
14609
  th = threading.Thread(target=scheduler_loop, kwargs={"log": log},
14610
  daemon=True, name="automation-scheduler")
 
2469
  #: ⭐⭐ WAVE 31 Β· T35 (D-134) β€” the definitions memo, and the two things that make it safe.
2470
  #: `tenant -> (monotonic_at, defs)`. Read ONLY through `all_definitions(..., cached=True)`, which
2471
  #: exactly one caller uses (`grid_hook`).
2472
+ _DEFS_MEMO = {}
2473
  #: Deliberately SHORT. This exists to collapse one burst of row events into one read, not to be a
2474
  #: cache β€” an import of 20,000 rows arrives in far less than this, and a stale trigger for two
2475
  #: seconds is bounded and recoverable where a stale one for a minute is a mystery.
2476
+ _DEFS_TTL = 2.0
2477
+
2478
+ # The durable external scheduler has a different read pattern from a row-event burst. A live
2479
+ # Space is one process / one replica today, and every automation-definition write in this module
2480
+ # passes through `_store_update`, so the scheduler can retain definitions until a REAL write
2481
+ # invalidates them. This is the cost boundary for Neon: an idle 15-minute wake-up must not turn
2482
+ # into 96 reads/day of the same tenant JSON value merely because a timer fired.
2483
+ _TICK_DEFS_MEMO = {}
2484
+ _TICK_CACHE_LOCK = threading.RLock()
2485
 
2486
 
2487
  def _store_update(rt, fn, flush="sync", keeps_defs=False):
 
2509
  trigger set for another tenant. The memo holds at most a handful of entries and the correct,
2510
  boring thing costs nothing.
2511
  """
2512
+ if not keeps_defs:
2513
+ _DEFS_MEMO.clear()
2514
+ with _TICK_CACHE_LOCK:
2515
+ _TICK_DEFS_MEMO.clear()
2516
+ return rt.update(STORE_KEY, fn, flush=flush)
2517
 
2518
 
2519
+ def all_definitions(rt, cached=False):
2520
  """Every automation definition for this tenant.
2521
 
2522
  ⭐⭐ `cached=True` IS D-134's FIX, AND IT IS OPT-IN FOR A REASON. `grid_hook` runs ONCE PER ROW
 
2546
  out = {str(aid): _without_retired_board(defn)[0] for aid, defn in raw.items()}
2547
  if cached:
2548
  _DEFS_MEMO[str(getattr(rt, "key", "") or "")] = (time.monotonic(), out)
2549
+ return out
2550
+
2551
+
2552
+ def tick_definitions(rt):
2553
+ """Definitions for the external scheduler, cached until `_store_update` changes them.
2554
+
2555
+ Live has one replica, all product writes use `_store_update`, and a restart naturally drops
2556
+ this process cache. The existing two-second memo remains dedicated to row-event bursts.
2557
+ """
2558
+ key = str(getattr(rt, "key", "") or "")
2559
+ with _TICK_CACHE_LOCK:
2560
+ hit = _TICK_DEFS_MEMO.get(key)
2561
+ if hit is not None:
2562
+ return hit
2563
+ fresh = all_definitions(rt)
2564
+ with _TICK_CACHE_LOCK:
2565
+ return _TICK_DEFS_MEMO.setdefault(key, fresh)
2566
+
2567
+
2568
+ def invalidate_tick_cache(tenant=None):
2569
+ """Drop scheduler control-plane state after an out-of-process tenant change."""
2570
+ with _TICK_CACHE_LOCK:
2571
+ if tenant is None:
2572
+ _TICK_DEFS_MEMO.clear()
2573
+ _TICK_TENANT_CACHE.clear()
2574
+ else:
2575
+ _TICK_DEFS_MEMO.pop(str(tenant), None)
2576
 
2577
 
2578
  def _new_id(existing):
 
3122
  is the part a test is allowed to call on its own."""
3123
 
3124
 
3125
+ def _release(tenant, auto_id):
3126
+ with _RUN_LOCK:
3127
+ _RUNNING.pop((tenant, str(auto_id)), None)
3128
+ # A completed/manual/event run may have changed rows that feed a metric, link or source
3129
+ # rollup. Mark the derived lane dirty; the next durable tick will refresh it once. Merely
3130
+ # waking the scheduler does not mark anything dirty and therefore does not poll Neon.
3131
+ _mark_derived_dirty(tenant)
3132
 
3133
 
3134
  def _commit_run(rt, auto_id, state, summary, counts, ok, affected=None, steps=None, notes=None):
 
12666
  or "").strip().lower()
12667
 
12668
 
12669
+ def compute_metric_cells(rt, table_key, today=None, tables=None, persist=False):
12670
  """Recompute every metric cell on ONE table from the master series. One coalesced write,
12671
  only when something actually changed (the flush-ceiling law); zero reads when the table
12672
  has no metric fields or the master is off. Returns the number of rows touched."""
12673
+ owned = tables is not None
12674
+ blob = tables if owned else None
12675
+ t = ((blob or {}).get(str(table_key)) if owned else ut_get(rt, table_key))
12676
  mfields = metric_fields_of_table(t)
12677
  if not mfields:
12678
  return 0
 
12706
  tt.setdefault("rows", {}).setdefault(rid, {}).update(vals)
12707
  return cur
12708
 
12709
+ if owned:
12710
+ _up(blob)
12711
+ if persist:
12712
+ rt.update(UT_STORE_KEY, _up, flush="sync")
12713
+ return len(changes)
12714
+ rt.update(UT_STORE_KEY, _up, flush="sync")
12715
+ return len(changes)
12716
 
12717
 
12718
  # ── ⭐⭐ THE RELATIONAL PASS (2026-08-07) β€” derived LINK cells and ROLLUP cells ────────────────
 
13251
  return touched
13252
 
13253
 
13254
+ def refresh_relations(rt, log=print, tables=None, persist=True):
13255
  """The tick half of the relational pass β€” the twin of `refresh_metrics`.
13256
 
13257
  ⚠ Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale
13258
  when table B gains a row, and A has no way to know that happened. Cheap by construction β€” a
13259
  table declaring neither kind costs one dict scan.
13260
  """
13261
+ if tables is not None:
13262
+ local = _refresh_relations_inplace(tables, log=log)
13263
+ if not local or not persist:
13264
+ return local
13265
+ actual = [0]
13266
+
13267
+ def _up(cur):
13268
+ cur = cur if isinstance(cur, dict) else {}
13269
+ actual[0] = _refresh_relations_inplace(cur, log=log)
13270
+ return cur
13271
+
13272
+ rt.update(UT_STORE_KEY, _up, flush="async")
13273
+ return actual[0]
13274
+
13275
+ snapshot = {
13276
  str(key): {**(table or {}),
13277
  "rows": {str(rid): dict(row or {})
13278
  for rid, row in ((table or {}).get("rows") or {}).items()}}
 
13305
  return actual[0]
13306
 
13307
 
13308
+ def refresh_metrics(rt, today=None, log=print, tables=None, persist=True):
13309
  """The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window
13310
  metric can never go staler than one tick while a scheduler exists. Cheap by construction β€”
13311
  a table without metric fields costs a dict scan and nothing else."""
13312
+ snapshot = tables if tables is not None else ut_all(rt)
13313
+ touched = 0
13314
+ for tk, t in snapshot.items():
13315
+ if metric_fields_of_table(t):
13316
+ try:
13317
+ touched += compute_metric_cells(rt, tk, today=today, tables=snapshot,
13318
+ persist=persist)
13319
  except Exception as e: # noqa: BLE001
13320
  log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}")
13321
  return touched
 
14504
  # THE TICK + the in-process scheduler
14505
  # ---------------------------------------------------------------------------------------------
14506
 
14507
+ def pending_collect_ids(rt, definitions=None):
14508
  """⭐ 2026-08-06 β€” automations holding a snapshot the vendor is still building.
14509
 
14510
  β›” THE DEFECT THIS CLOSES, reported live: *"why is the SMALL 10 records test search taking
 
14521
  make an unscheduled automation's paid result depend on someone remembering to press a button.
14522
  """
14523
  out = []
14524
+ definitions = all_definitions(rt) if definitions is None else definitions
14525
+ for aid, d in definitions.items():
14526
  if not isinstance(d, dict):
14527
  continue
14528
  if (d.get("trigger") or {}).get("paused"):
 
14551
  return sorted(out)
14552
 
14553
 
14554
+ def due_ids(rt, now=None, definitions=None):
14555
+ """Which of this tenant's automations a tick at `now` should start. Pure over the store."""
14556
+ definitions = all_definitions(rt) if definitions is None else definitions
14557
+ return sorted(aid for aid, d in definitions.items() if is_due(d, now))
14558
 
14559
 
14560
+ def tick(rt, tenant, now=None, log=print):
14561
  """Fire every due automation for ONE tenant + poll every email trigger (C3). Returns the
14562
  ids started. The email polls are bounded and fail-quiet per automation β€” one broken
14563
  mailbox connection must not stop the tenant's schedules."""
14564
+ started = []
14565
+ definitions = tick_definitions(rt)
14566
  # ⭐ COLLECT-FIRST (2026-08-06). A snapshot the vendor has finished building is a result the
14567
  # tenant has already been charged for; it is collected whether or not this automation is on a
14568
  # schedule. `_claim` makes the union safe β€” an id in both lists starts once.
14569
+ for aid in dict.fromkeys(list(pending_collect_ids(rt, definitions=definitions))
14570
+ + list(due_ids(rt, now, definitions=definitions))):
14571
  if run_async(rt, tenant, aid, username="scheduler", log=log):
14572
  started.append(aid)
14573
+ for aid, d in definitions.items():
14574
  if (d.get("trigger") or {}).get("key") == "email":
14575
  try:
14576
  if email_poll(rt, tenant, aid, d, log=log) is not None:
14577
  started.append(aid)
14578
  except Exception as e: # noqa: BLE001
14579
  log(f"[aios-auto] email poll {tenant}/{aid} failed: {type(e).__name__}: {e}")
14580
+ # Derived cells are maintenance, not part of the scheduler's HTTP acknowledgement. The
14581
+ # background lane reads the large workspace at most once per UTC day, plus once after a real
14582
+ # automation run marks it dirty. An idle wake-up therefore stays rows-free, and Lambda never
14583
+ # times out waiting for a 40 MB tenant document to cross the public network.
14584
+ _start_derived_refresh(rt, tenant, now=now, log=log)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14585
  if started:
14586
  log(f"[aios-auto] tick {tenant}: started {', '.join(started)}")
14587
+ return started
14588
+
14589
+
14590
+ # Postgres-only memo for the expensive derived half of the tick. The marker combines the
14591
+ # durable workspace revision with the UTC calendar day: unchanged minutes do no full-document
14592
+ # read, while midnight and a real workspace write each trigger one correct refresh.
14593
+ _DERIVED_TICK_STATE = {}
14594
+ _DERIVED_TICK_LOCKS = {}
14595
+ _DERIVED_TICK_LOCKS_GUARD = threading.Lock()
14596
+ _DERIVED_DIRTY_GENERATION = {}
14597
+ _DERIVED_RUNNING = {}
14598
+ _DERIVED_WORK_SEMAPHORE = threading.Semaphore(1)
14599
+ _TICK_TENANT_CACHE = {}
14600
+ _SCHEDULER_METRICS = {
14601
+ "ticks": 0,
14602
+ "lastTickAt": "",
14603
+ "lastTenantCount": 0,
14604
+ "workspaceRefreshes": 0,
14605
+ "lastWorkspaceRefreshAt": "",
14606
+ }
14607
+
14608
+
14609
+ def _derived_tick_lock(tenant):
14610
+ key = str(tenant)
14611
+ with _DERIVED_TICK_LOCKS_GUARD:
14612
+ return _DERIVED_TICK_LOCKS.setdefault(key, threading.Lock())
14613
+
14614
+
14615
+ def _mark_derived_dirty(tenant):
14616
+ """Record a real automation run without reading the workspace it may have changed."""
14617
+ key = str(tenant)
14618
+ with _DERIVED_TICK_LOCKS_GUARD:
14619
+ _DERIVED_DIRTY_GENERATION[key] = _DERIVED_DIRTY_GENERATION.get(key, 0) + 1
14620
+
14621
+
14622
+ def _derived_refresh_due(rt, tenant, now=None, state=None, backend=None):
14623
+ actual_backend = backend if backend is not None else os.environ.get("STORE_BACKEND", "hf")
14624
+ if str(actual_backend).strip().lower() != "pg":
14625
+ return True, None
14626
+ stamp = now or _dt.datetime.now(_dt.timezone.utc)
14627
+ if isinstance(stamp, _dt.datetime):
14628
+ stamp = (stamp.replace(tzinfo=_dt.timezone.utc) if stamp.tzinfo is None
14629
+ else stamp.astimezone(_dt.timezone.utc))
14630
+ day = stamp.date().isoformat() if hasattr(stamp, "date") else str(stamp)[:10]
14631
+ memo = _DERIVED_TICK_STATE if state is None else state
14632
+ previous = memo.get(str(tenant))
14633
+ with _DERIVED_TICK_LOCKS_GUARD:
14634
+ dirty = bool(_DERIVED_DIRTY_GENERATION.get(str(tenant), 0)) if state is None else False
14635
+ # The old order queried `store_kv.rev` every 15 minutes and therefore woke a suspended Neon
14636
+ # compute even when nothing had changed. Human row routes already coalesce relation refreshes,
14637
+ # Odoo rebuilds refresh their own cells, and automation completion marks this lane dirty. So a
14638
+ # same-day clean marker is sufficient evidence to do literally no database work on an idle tick.
14639
+ if (not dirty and isinstance(previous, (tuple, list)) and len(previous) > 1
14640
+ and str(previous[1]) == day):
14641
+ return False, tuple(previous)
14642
+ try:
14643
+ rev = (rt.revision(UT_STORE_KEY) or {}).get("token")
14644
+ if not rev:
14645
+ return True, None
14646
+ except Exception:
14647
+ return True, None
14648
+ marker = (str(rev), day)
14649
+ return memo.get(str(tenant)) != marker, marker
14650
+
14651
+
14652
+ def _refresh_source_rollups(rt, tenant, log=print, tables=None):
14653
+ import rollup_sql
14654
+ snapshot = tables if tables is not None else (rt.get(UT_STORE_KEY) or {})
14655
+ for table_key, table in list(snapshot.items()):
14656
+ if rollup_sql.source_fields(table or {}):
14657
+ n = rollup_sql.compute(rt, table_key, tables=snapshot, persist=True)
14658
+ if n:
14659
+ log(f"[aios-auto] source rollups {tenant}/{table_key}: {n}")
14660
+
14661
+
14662
+ def _derived_refresh_worker(rt, tenant, marker, generation, log=print):
14663
+ """Run one coalesced derived pass away from the external tick's HTTP response."""
14664
+ key = str(tenant)
14665
+ ok = True
14666
+ # One rows-bearing pass at a time across the whole process. Four tenants currently total
14667
+ # about 67 MB of workspace JSON; serialising that maintenance avoids a restart turning into
14668
+ # four simultaneous Neon result streams and a compute autoscale spike.
14669
+ with _DERIVED_WORK_SEMAPHORE, _derived_tick_lock(key):
14670
+ try:
14671
+ tables = dict(rt.get(UT_STORE_KEY) or {})
14672
+ except Exception as e: # noqa: BLE001
14673
+ tables = None
14674
+ ok = False
14675
+ log(f"[aios-auto] derived workspace snapshot {key} failed: "
14676
+ f"{type(e).__name__}: {e}")
14677
+ if tables is not None:
14678
+ for label, fn in (
14679
+ ("metric", lambda: refresh_metrics(rt, log=log, tables=tables)),
14680
+ ("relation", lambda: refresh_relations(rt, log=log, tables=tables)),
14681
+ ("source rollup", lambda: _refresh_source_rollups(
14682
+ rt, key, log=log, tables=tables))):
14683
+ try:
14684
+ fn()
14685
+ except Exception as e: # noqa: BLE001
14686
+ ok = False
14687
+ log(f"[aios-auto] {label} refresh {key} failed: "
14688
+ f"{type(e).__name__}: {e}")
14689
+ with _DERIVED_TICK_LOCKS_GUARD:
14690
+ _DERIVED_RUNNING.pop(key, None)
14691
+ if ok and marker is not None:
14692
+ _DERIVED_TICK_STATE[key] = marker
14693
+ # Do not erase a write that landed after this worker took its snapshot. Its newer
14694
+ # generation remains dirty and the next external tick repairs it.
14695
+ if _DERIVED_DIRTY_GENERATION.get(key, 0) == generation:
14696
+ _DERIVED_DIRTY_GENERATION.pop(key, None)
14697
+ _SCHEDULER_METRICS["workspaceRefreshes"] += 1
14698
+ _SCHEDULER_METRICS["lastWorkspaceRefreshAt"] = _iso()
14699
+
14700
+
14701
+ def _start_derived_refresh(rt, tenant, now=None, log=print):
14702
+ """Start at most one rows-bearing maintenance worker for a tenant. Returns whether started."""
14703
+ due, marker = _derived_refresh_due(rt, tenant, now=now)
14704
+ if not due:
14705
+ return False
14706
+ key = str(tenant)
14707
+ with _DERIVED_TICK_LOCKS_GUARD:
14708
+ if key in _DERIVED_RUNNING:
14709
+ return False
14710
+ generation = _DERIVED_DIRTY_GENERATION.get(key, 0)
14711
+ _DERIVED_RUNNING[key] = generation
14712
+ thread = threading.Thread(
14713
+ target=_derived_refresh_worker,
14714
+ args=(rt, key, marker, generation, log),
14715
+ daemon=True,
14716
+ name=f"automation-derived-{key}")
14717
+ thread.start()
14718
+ return True
14719
+
14720
+
14721
+ def _pg_tick_refusal(tenant_slug, backend=None, write_refusal=None):
14722
+ actual_backend = backend if backend is not None else os.environ.get("STORE_BACKEND", "hf")
14723
+ if str(actual_backend).strip().lower() != "pg":
14724
+ return None
14725
+ if write_refusal is None:
14726
+ import core.store_pg as _pg
14727
+ write_refusal = _pg.write_refusal
14728
+ return write_refusal(str(tenant_slug))
14729
+
14730
+
14731
+ def _scheduler_tenants(runtime_module, now=None):
14732
+ """Known tenants, refreshed once per UTC day rather than on every external wake-up."""
14733
+ stamp = now or _dt.datetime.now(_dt.timezone.utc)
14734
+ if isinstance(stamp, _dt.datetime):
14735
+ stamp = (stamp.replace(tzinfo=_dt.timezone.utc) if stamp.tzinfo is None
14736
+ else stamp.astimezone(_dt.timezone.utc))
14737
+ day = stamp.date().isoformat() if hasattr(stamp, "date") else str(stamp)[:10]
14738
+ with _TICK_CACHE_LOCK:
14739
+ if _TICK_TENANT_CACHE.get("day") == day:
14740
+ return list(_TICK_TENANT_CACHE.get("slugs") or [])
14741
+ slugs = list(runtime_module.known_tenants())
14742
+ with _TICK_CACHE_LOCK:
14743
+ _TICK_TENANT_CACHE.update({"day": day, "slugs": tuple(slugs)})
14744
+ return slugs
14745
+
14746
+
14747
+ def scheduler_status():
14748
+ """Safe process-local observability for the operator plane and tick response."""
14749
+ backend = str(os.environ.get("STORE_BACKEND") or "hf").lower()
14750
+ external = bool(os.environ.get("AIOS_AUTOMATION_TICK_TOKEN"))
14751
+ # A thread object surviving a test/config transition must never advertise a forbidden Pg
14752
+ # scheduler. Production's source of truth is the backend invariant, not stale process state.
14753
+ in_process = bool(backend != "pg" and _SCHEDULER[0] is not None
14754
+ and _SCHEDULER[0].is_alive())
14755
+ source = "external" if external else ("in-process" if in_process else "")
14756
+ with _TICK_CACHE_LOCK, _DERIVED_TICK_LOCKS_GUARD:
14757
+ return {
14758
+ "enabled": bool(external or in_process),
14759
+ "source": source,
14760
+ "backend": backend,
14761
+ "definitionCaches": len(_TICK_DEFS_MEMO),
14762
+ "tenantCacheDay": str(_TICK_TENANT_CACHE.get("day") or ""),
14763
+ "derivedRunning": len(_DERIVED_RUNNING),
14764
+ **dict(_SCHEDULER_METRICS),
14765
+ }
14766
+
14767
+
14768
+ def tick_all(now=None, log=print):
14769
  """Every registered tenant. Fail-quiet per tenant: one tenant's broken store must not stop
14770
  the others' schedules."""
14771
+ from harness import runtime as _rt
14772
+ out = {}
14773
+ slugs = _scheduler_tenants(_rt, now=now)
14774
+ for slug in slugs:
14775
+ try:
14776
+ refusal = _pg_tick_refusal(slug)
14777
+ if refusal:
14778
+ log(f"[aios-auto] tick {slug} skipped before runtime read: {refusal}")
14779
+ continue
14780
+ out[slug] = tick(_rt.get_runtime(slug), slug, now=now, log=log)
14781
+ except Exception as e: # noqa: BLE001
14782
+ log(f"[aios-auto] tick {slug} skipped: {type(e).__name__}: {e}")
14783
+ with _TICK_CACHE_LOCK:
14784
+ _SCHEDULER_METRICS["ticks"] += 1
14785
+ _SCHEDULER_METRICS["lastTickAt"] = _iso()
14786
+ _SCHEDULER_METRICS["lastTenantCount"] = len(slugs)
14787
+ return out
14788
 
14789
 
14790
  #: How often the in-process scheduler wakes. A minute is the cron resolution; anything finer
 
14808
  log(f"[aios-auto] scheduler tick failed: {type(e).__name__}: {e}")
14809
 
14810
 
14811
+ def start_scheduler(log=print):
14812
  """Start the loop once per process. **OPT-IN: `AIOS_AUTOMATIONS=1`.**
14813
 
14814
  ⚠ AMENDED 2026-08-03 (D), and the reason is a measurement rather than a preference. The wave
 
14825
  external EventBridge tick (R5) does not depend on it either way, since that POSTs the
14826
  endpoint rather than riding this thread.
14827
  """
14828
+ if os.environ.get("AIOS_AUTOMATIONS") != "1":
14829
+ return False
14830
+ # PostgreSQL production has one durable wake-up: EventBridge -> Lambda -> protected endpoint.
14831
+ # Refuse the old resident minute loop even if a stale secret is reintroduced; two schedulers
14832
+ # were the measured cause of the Neon transfer incident this guard closes.
14833
+ if str(os.environ.get("STORE_BACKEND") or "hf").strip().lower() == "pg":
14834
+ log("[aios-auto] in-process scheduler refused on PostgreSQL; use the external tick")
14835
+ return False
14836
+ if _SCHEDULER[0] is not None:
14837
  return False
14838
  th = threading.Thread(target=scheduler_loop, kwargs={"log": log},
14839
  daemon=True, name="automation-scheduler")
api/rollup_sql.py CHANGED
@@ -125,8 +125,8 @@ def _today():
125
  return _dt.date.today().strftime("%Y-%m-%d")
126
 
127
 
128
- def compute(rt, table_key, today=None, tables=None):
129
- """Write every source-backed rollup cell on `table_key`. Returns `{field_key: cells_written}`.
130
 
131
  `tables` (a live `user_tables` dict) is the gate's injection point β€” the same shape
132
  `automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
@@ -167,13 +167,25 @@ def compute(rt, table_key, today=None, tables=None):
167
  hit = values.get(join)
168
  if hit is None and join.endswith(".0"):
169
  hit = values.get(join[:-2])
170
- row[fkey] = "" if hit is None else str(hit)
171
- n += 1
 
 
172
  written[fkey] = n
173
  return cur
174
 
175
  if owned:
176
  _apply(blob)
177
- else:
 
 
 
178
  rt.update(ut.STORE_KEY, _apply, flush="async")
179
- return written
 
 
 
 
 
 
 
 
125
  return _dt.date.today().strftime("%Y-%m-%d")
126
 
127
 
128
+ def compute(rt, table_key, today=None, tables=None, persist=False):
129
+ """Write changed source-backed rollup cells on `table_key`.
130
 
131
  `tables` (a live `user_tables` dict) is the gate's injection point β€” the same shape
132
  `automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
 
167
  hit = values.get(join)
168
  if hit is None and join.endswith(".0"):
169
  hit = values.get(join[:-2])
170
+ value = "" if hit is None else str(hit)
171
+ if row.get(fkey) != value:
172
+ row[fkey] = value
173
+ n += 1
174
  written[fkey] = n
175
  return cur
176
 
177
  if owned:
178
  _apply(blob)
179
+ changed = {k: n for k, n in written.items() if n}
180
+ if not changed or not persist:
181
+ return changed
182
+ written.clear()
183
  rt.update(ut.STORE_KEY, _apply, flush="async")
184
+ return {k: n for k, n in written.items() if n}
185
+
186
+ _apply(blob)
187
+ if not any(written.values()):
188
+ return {}
189
+ written.clear()
190
+ rt.update(ut.STORE_KEY, _apply, flush="async")
191
+ return {k: n for k, n in written.items() if n}
api/routes_automation.py CHANGED
@@ -560,19 +560,10 @@ def _triggers_vocab(session):
560
  return out
561
 
562
 
563
- def _tick_state():
564
- """⭐ WAVE 21 (C6 amendment A1) β€” can a SCHEDULE fire on this deployment?
565
-
566
- TWO independent paths can: the in-process scheduler (`AIOS_AUTOMATIONS=1`,
567
- `automation_engine.py` module bottom) and an external cron POSTing `/automations/tick`,
568
- gated on `AIOS_AUTOMATION_TICK_TOKEN` (AWS EventBridge in production). The Step-1 Trigger
569
- card must be honest in both directions: "schedules won't fire" on a deployment where
570
- EventBridge demonstrably fires them daily is the exact lie R9 forbids. `external` means
571
- "the door is OPEN", never "the caller is alive" β€” the client's copy says so."""
572
- inproc = os.environ.get("AIOS_AUTOMATIONS") == "1"
573
- ext = bool(os.environ.get("AIOS_AUTOMATION_TICK_TOKEN"))
574
- return {"enabled": bool(inproc or ext),
575
- "source": "in-process" if inproc else ("external" if ext else "")}
576
 
577
 
578
  #: W29-T01 β€” the Board retirement ran, per tenant, this process. Same shape and same reasoning as
@@ -1773,10 +1764,11 @@ def tick(request: Request, x_aios_tick_token: str = Header(default="")):
1773
  raise err(403, "tick_disabled",
1774
  "AIOS_AUTOMATION_TICK_TOKEN is not configured. The tick endpoint is closed")
1775
  got = x_aios_tick_token or request.headers.get("X-AIOS-TICK-TOKEN") or ""
1776
- if got != want:
1777
- raise err(403, "bad_tick_token", "that token is not valid for this deployment")
1778
- started = engine.tick_all()
1779
- return {"started": started, "at": time.strftime("%Y-%m-%dT%H:%M:%S")}
 
1780
 
1781
 
1782
  @router.post("/automations/ig/purge")
 
560
  return out
561
 
562
 
563
+ def _tick_state():
564
+ """Can a schedule fire here? PostgreSQL production has one external scheduler only."""
565
+ status = engine.scheduler_status()
566
+ return {"enabled": bool(status.get("enabled")), "source": status.get("source") or ""}
 
 
 
 
 
 
 
 
 
567
 
568
 
569
  #: W29-T01 β€” the Board retirement ran, per tenant, this process. Same shape and same reasoning as
 
1764
  raise err(403, "tick_disabled",
1765
  "AIOS_AUTOMATION_TICK_TOKEN is not configured. The tick endpoint is closed")
1766
  got = x_aios_tick_token or request.headers.get("X-AIOS-TICK-TOKEN") or ""
1767
+ if got != want:
1768
+ raise err(403, "bad_tick_token", "that token is not valid for this deployment")
1769
+ started = engine.tick_all()
1770
+ return {"started": started, "at": time.strftime("%Y-%m-%dT%H:%M:%S"),
1771
+ "scheduler": engine.scheduler_status()}
1772
 
1773
 
1774
  @router.post("/automations/ig/purge")
api/routes_platform_admin.py CHANGED
@@ -568,6 +568,7 @@ def platform_connectors(tenant: str = "", session: Session = Depends(padmin_gate
568
  @router.get("/automations")
569
  def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
570
  """Every tenant's automations, their schedules and run history, plus the fleet cost model."""
 
571
  out, errors, retained = [], {}, 20
572
  for t, rt in _scan(tenant):
573
  slug = t["slug"]
@@ -580,11 +581,13 @@ def platform_automations(tenant: str = "", session: Session = Depends(padmin_gat
580
  continue
581
  retained = max_runs
582
  out += rows
 
583
  return {"automations": out, "count": len(out),
584
  "enabled": sum(1 for a in out if a["enabled"]),
585
  "historyRetained": retained,
586
  "cost": _automation_cost(out), "errors": errors,
587
- "tickEnabled": os.environ.get("AIOS_AUTOMATIONS") == "1"}
 
588
 
589
 
590
  @router.get("/aws")
 
568
  @router.get("/automations")
569
  def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
570
  """Every tenant's automations, their schedules and run history, plus the fleet cost model."""
571
+ import automation_engine as engine
572
  out, errors, retained = [], {}, 20
573
  for t, rt in _scan(tenant):
574
  slug = t["slug"]
 
581
  continue
582
  retained = max_runs
583
  out += rows
584
+ scheduler = engine.scheduler_status()
585
  return {"automations": out, "count": len(out),
586
  "enabled": sum(1 for a in out if a["enabled"]),
587
  "historyRetained": retained,
588
  "cost": _automation_cost(out), "errors": errors,
589
+ "tickEnabled": bool(scheduler.get("enabled")),
590
+ "scheduler": scheduler}
591
 
592
 
593
  @router.get("/aws")
platform/harness/runtime.py CHANGED
@@ -179,6 +179,10 @@ class TenantRuntime:
179
  """
180
  return self._store().get_projection(self.store_key(name), drop=drop)
181
 
 
 
 
 
182
  def put(self, name, data):
183
  return self._store().put(self.store_key(name), data)
184
 
 
179
  """
180
  return self._store().get_projection(self.store_key(name), drop=drop)
181
 
182
+ def revision(self, name):
183
+ """Return the tiny durable change token for this tenant's addressed bucket."""
184
+ return self._store().revision(self.store_key(name))
185
+
186
  def put(self, name, data):
187
  return self._store().put(self.store_key(name), data)
188