spine-framework 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.framework/functions/_shared/agent-runner.ts +1000 -81
  2. package/.framework/functions/_shared/cil.ts +2 -1
  3. package/.framework/functions/_shared/db.ts +3 -6
  4. package/.framework/functions/_shared/kb.ts +61 -7
  5. package/.framework/functions/_shared/message-sequence.ts +53 -0
  6. package/.framework/functions/_shared/pipeline-runner.ts +150 -37
  7. package/.framework/functions/_shared/principal.ts +9 -6
  8. package/.framework/functions/_shared/trigger-engine.ts +149 -40
  9. package/.framework/functions/admin-data.ts +9 -3
  10. package/.framework/functions/ai-agents.ts +93 -27
  11. package/.framework/functions/pipeline-runner-background.ts +120 -0
  12. package/.framework/functions/system-cron.ts +10 -1
  13. package/.framework/functions/trigger-scheduler.ts +65 -5
  14. package/.framework/migrations/064_episode_leases.sql +196 -0
  15. package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
  16. package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
  17. package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
  18. package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
  19. package/.framework/schemas/spine.config.schema.json +62 -0
  20. package/.framework/src/App.tsx +3 -1
  21. package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
  22. package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
  23. package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
  24. package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
  25. package/.framework/src/components/layout/Layout.tsx +4 -2
  26. package/.framework/src/components/layout/Sidebar.tsx +4 -2
  27. package/.framework/src/hooks/useBranding.ts +47 -0
  28. package/.framework/src/lib/branding.ts +128 -0
  29. package/.framework/src/pages/NoAppPage.tsx +3 -1
  30. package/.framework/src/pages/SetupPage.tsx +5 -2
  31. package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
  32. package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
  33. package/.framework/src/pages/auth/LoginPage.tsx +4 -2
  34. package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
  35. package/.framework/templates/project.json +1 -0
  36. package/.framework/tests/unit/agent-runner.test.ts +17 -3
  37. package/.framework/tests/unit/episode-lease.test.ts +695 -0
  38. package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
  39. package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
  40. package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
  41. package/.framework/tests/unit/principal-cache.test.ts +15 -0
  42. package/.framework/tests/unit/trigger-engine.test.ts +278 -91
  43. package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
  44. package/netlify.toml +5 -0
  45. package/package.json +1 -1
  46. package/templates/project/package-lock.json +2 -2
@@ -1,10 +1,70 @@
1
1
  // CIL[BEGIN stable_id="~/.framework/functions/trigger-scheduler.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/functions/trigger-scheduler.ts"]
2
2
  // CIL[DOC-MACRO purpose="Scheduled entry point that invokes the shared system-cron runner so Netlify's every-minute trigger dispatches due scheduled actions."]
3
- // CIL[DOC-RESPONSIBILITY responsibility="Re-export the system-cron handler under the function name registered in netlify.toml."]
4
- // CIL[DOC-CONTEXT module="trigger-scheduler" layer="functions" related_files=".framework/functions/system-cron.ts, netlify.toml"]
5
- // CIL[DOC-CONTRACT inputs="Netlify scheduled invocation" outputs="system-cron sweep result" errors="none (delegates)" side_effects="runs due schedule actions across accounts"]
6
- // CIL[DOC-MICRO logic_summary="Thin alias: netlify.toml schedules trigger-scheduler every minute; the handler is system-cron's." notable_decisions="Separate file so the schedule name stays stable if system-cron internals change."]
3
+ // CIL[DOC-RESPONSIBILITY responsibility="Detect Netlify Clockwork scheduled invocations (which carry no credentials) and run system-cron under the system principal; forward everything else to system-cron's normal middleware auth."]
4
+ // CIL[DOC-CONTEXT module="trigger-scheduler" layer="functions" related_files=".framework/functions/system-cron.ts, .framework/functions/_shared/principal.ts, .framework/functions/_shared/db.ts, netlify.toml"]
5
+ // CIL[DOC-CONTRACT inputs="Netlify scheduled invocation (x-nf-event/x-netlify-event: schedule OR Netlify Clockwork UA, + {\"next_run\"} body) or authenticated HTTP request" outputs="system-cron sweep result" errors="401 for unauthenticated HTTP requests" side_effects="runs due schedule actions across accounts"]
6
+ // CIL[DOC-MICRO logic_summary="Netlify's scheduler invokes this function directly. The header name differs between environments: CLI emulation sends X-NF-Event; production sends x-netlify-event (canonical constant in @netlify/serverless-functions-api). Both are accepted, as is the Netlify Clockwork user-agent, so detection is robust across all invocation paths. The body must contain next_run as a string for both headers. For that path a RequestContext is fabricated with SYSTEM_PRINCIPAL and passed straight into system-cron's handler. Non-scheduled requests fall through to system-cron's createHandler." notable_decisions="x-netlify-event is the production canonical header (NFEvent in @netlify/serverless-functions-api); x-nf-event is the CLI emulation header. Both must be accepted or every scheduled tick 401s in production. Netlify Clockwork UA is a third indicator used only when neither header is present."]
7
7
  // CIL[DOC-STABILITY stable]
8
8
  // CIL[DOC-AUDIENCE audience="framework maintainers"]
9
- export { handler } from './system-cron'
9
+ import { handler as cronHandler } from './system-cron'
10
+ import { SYSTEM_PRINCIPAL } from './_shared/principal'
11
+ import { adminDb } from './_shared/db'
12
+
13
+ // Netlify Clockwork invocation detection. Require BOTH a recognised indicator
14
+ // AND the {"next_run": "..."} body shape so a bare spoofed header or UA alone
15
+ // cannot authenticate.
16
+ //
17
+ // Header variants (both must be handled):
18
+ // - x-nf-event: schedule — Netlify CLI emulation (ntl dev / functions:invoke)
19
+ // - x-netlify-event: schedule — production canonical header
20
+ // (@netlify/serverless-functions-api NFEvent = "x-netlify-event")
21
+ //
22
+ // Fallback: user-agent "Netlify Clockwork" is accepted when neither header is
23
+ // present, to guard against future header renames.
24
+ function isNetlifyScheduledInvocation(event: any): boolean {
25
+ const headers = event.headers || {}
26
+
27
+ const nfEvent =
28
+ headers['x-netlify-event'] || headers['X-Netlify-Event'] ||
29
+ headers['x-nf-event'] || headers['X-NF-Event']
30
+
31
+ const ua = headers['user-agent'] || headers['User-Agent'] || ''
32
+
33
+ const hasScheduleIndicator =
34
+ nfEvent === 'schedule' || ua.includes('Netlify Clockwork')
35
+
36
+ if (!hasScheduleIndicator) return false
37
+
38
+ try {
39
+ const body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body
40
+ return typeof body?.next_run === 'string'
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ export const handler = async (event: any, context: any) => {
47
+ if (!isNetlifyScheduledInvocation(event)) {
48
+ // Not a Clockwork invocation — run the normal middleware auth path.
49
+ // x-internal-secret resolves to SYSTEM_PRINCIPAL; ?api_key= is checked
50
+ // inside the cron handler; anything else 401s.
51
+ return cronHandler(event, context)
52
+ }
53
+
54
+ // Fabricate a RequestContext carrying the system principal. createHandler
55
+ // short-circuits when event.requestId + event.principal are present, so
56
+ // this flows straight into the cron handler without re-resolving auth.
57
+ const ctx = {
58
+ requestId: crypto.randomUUID(),
59
+ principal: SYSTEM_PRINCIPAL,
60
+ db: adminDb,
61
+ accountId: null,
62
+ appId: null,
63
+ query: {},
64
+ requestPath: event.path || '/.netlify/functions/trigger-scheduler',
65
+ headers: {},
66
+ source: 'netlify-scheduler',
67
+ }
68
+ return cronHandler(ctx, context)
69
+ }
10
70
  // CIL[END stable_id="~/.framework/functions/trigger-scheduler.ts:module_header:module_header"]
@@ -0,0 +1,196 @@
1
+ -- CIL[BEGIN stable_id="~/.framework/migrations/064_episode_leases.sql:episode_leases:file" type="file" identity="episode_leases" parent="~/.framework/migrations/064_episode_leases.sql"]
2
+ -- CIL[DOC-MACRO purpose="Atomic single-flight lease for agent episodes: episode_leases table plus service-role-only acquire/heartbeat/release RPCs. Replaces the check-then-write driver_id claim in agent-runner so concurrent kicks can never both drive a thread. Also adds record_trigger_fire, the atomic trigger bookkeeping RPC that replaces trigger-engine's rpc-Promise-in-UPDATE bug."]
3
+ -- CIL[DOC-RESPONSIBILITY responsibility="Create public.episode_leases (one row per in-flight episode keyed by thread_id), provide acquire_episode_lease/heartbeat_episode_lease/release_episode_lease as the single atomic writers, and fence writes by lease_token."]
4
+ -- CIL[DOC-CONTEXT module="migrations" layer="database" related_files=".framework/functions/_shared/agent-runner.ts,.framework/functions/ai-agents.ts,.framework/migrations/054_kb_ingest_lease.sql"]
5
+ -- CIL[DOC-CONTRACT inputs="acquire: p_thread_id/p_account_id/p_driver_id/p_ttl_seconds; heartbeat: p_thread_id/p_lease_token/p_ttl_seconds; release: p_thread_id/p_lease_token" outputs="acquire jsonb {outcome: claimed|held, lease_token?, holder_driver_id?}; heartbeat boolean; release boolean" errors="none — all branches return" side_effects="Upserts/updates/deletes episode_leases; agent-runner uses the lease to serialize drivers and fence takeover writes"]
6
+ -- CIL[DOC-MICRO logic_summary="acquire_episode_lease INSERTs ON CONFLICT DO NOTHING; on conflict it SELECTs FOR UPDATE and renews when the caller is the same driver or the lease expired (takeover, fresh token), else returns 'held' with the live holder's driver_id. heartbeat/release are token-gated — a displaced driver's heartbeat fails, which is what fences its pending writes." notable_decisions="Lease is keyed by thread (not episode) because a thread can only ever have one live episode; driver_id is text to carry client-generated session tokens; same-driver re-acquire renews so step-mode drivers persist across HTTP requests; RLS grants service_role FOR ALL and EXECUTE goes to service_role only, matching 049/052/054's service-only convention"]
7
+ -- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ -- CIL[DOC-STABILITY stability="stable"]
9
+
10
+ -- ── episode_leases ─────────────────────────────────────────────────
11
+ -- One row per in-flight agent episode (threads.id). Entry paths
12
+ -- (ticket-creation kicks, episode-mode POSTs, step/complete POSTs)
13
+ -- acquire before running; the holder heartbeats per turn and releases
14
+ -- on terminal state. A live lease refuses competing drivers atomically —
15
+ -- this replaces the read-then-write research_state claim that allowed
16
+ -- two drivers to race within the same window.
17
+ CREATE TABLE IF NOT EXISTS public.episode_leases (
18
+ thread_id uuid PRIMARY KEY,
19
+ account_id uuid NOT NULL,
20
+ driver_id text NOT NULL,
21
+ lease_token uuid NOT NULL,
22
+ expires_at timestamptz NOT NULL,
23
+ created_at timestamptz DEFAULT now(),
24
+ updated_at timestamptz DEFAULT now()
25
+ );
26
+
27
+ ALTER TABLE public.episode_leases ENABLE ROW LEVEL SECURITY;
28
+
29
+ -- Service-role only: all reads/writes flow through _shared/agent-runner.ts
30
+ -- (adminDb) and the three RPCs below.
31
+ DROP POLICY IF EXISTS episode_leases_service_role ON public.episode_leases;
32
+ CREATE POLICY episode_leases_service_role ON public.episode_leases
33
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
34
+
35
+ -- ── acquire_episode_lease ──────────────────────────────────────────
36
+ -- Atomic claim. Returns {outcome, lease_token?, holder_driver_id?}:
37
+ -- claimed — caller owns the episode; must heartbeat/release with the token
38
+ -- held — a live driver owns the thread; caller watches or retries later
39
+ -- The same driver re-acquiring renews (new token, fresh TTL) so a client
40
+ -- step loop can hold the lease across HTTP requests.
41
+ CREATE OR REPLACE FUNCTION public.acquire_episode_lease(
42
+ p_thread_id uuid,
43
+ p_account_id uuid,
44
+ p_driver_id text,
45
+ p_ttl_seconds integer DEFAULT 90
46
+ )
47
+ RETURNS jsonb
48
+ LANGUAGE plpgsql
49
+ SECURITY DEFINER
50
+ SET search_path = public
51
+ AS $$
52
+ DECLARE
53
+ v_lease public.episode_leases%ROWTYPE;
54
+ v_new_token uuid := gen_random_uuid();
55
+ BEGIN
56
+ -- Fast path: claim the empty slot. The thread_id PK serializes
57
+ -- concurrent first-writers — the loser's INSERT is a no-op.
58
+ INSERT INTO public.episode_leases (thread_id, account_id, driver_id, lease_token, expires_at)
59
+ VALUES (
60
+ p_thread_id,
61
+ p_account_id,
62
+ p_driver_id,
63
+ v_new_token,
64
+ now() + make_interval(secs => p_ttl_seconds)
65
+ )
66
+ ON CONFLICT (thread_id) DO NOTHING;
67
+
68
+ IF FOUND THEN
69
+ RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
70
+ END IF;
71
+
72
+ -- Conflict path: lock the row so a concurrent release/takeover cannot
73
+ -- interleave between our read and our verdict.
74
+ SELECT * INTO v_lease
75
+ FROM public.episode_leases
76
+ WHERE thread_id = p_thread_id
77
+ FOR UPDATE;
78
+
79
+ -- A live different driver owns the episode — refuse, surfacing the
80
+ -- holder so the caller can attribute the 'running' status.
81
+ IF v_lease.driver_id <> p_driver_id AND v_lease.expires_at > now() THEN
82
+ RETURN jsonb_build_object(
83
+ 'outcome', 'held',
84
+ 'holder_driver_id', v_lease.driver_id
85
+ );
86
+ END IF;
87
+
88
+ -- Same driver (renew) or expired lease (takeover): fresh token + TTL.
89
+ -- A displaced driver's later heartbeat/release fails the token check,
90
+ -- which is what fences its pending writes.
91
+ UPDATE public.episode_leases SET
92
+ driver_id = p_driver_id,
93
+ lease_token = v_new_token,
94
+ account_id = p_account_id,
95
+ expires_at = now() + make_interval(secs => p_ttl_seconds),
96
+ updated_at = now()
97
+ WHERE thread_id = p_thread_id;
98
+
99
+ RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
100
+ END;
101
+ $$;
102
+
103
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) FROM PUBLIC;
104
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) FROM anon;
105
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) FROM authenticated;
106
+ GRANT EXECUTE ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) TO service_role;
107
+
108
+ -- ── heartbeat_episode_lease ────────────────────────────────────────
109
+ -- Token-gated TTL extension. Returns false when the lease_token no
110
+ -- longer matches (an expired-lease takeover displaced this driver) —
111
+ -- the caller must treat that as a fence and stop writing, not as an
112
+ -- error to retry.
113
+ CREATE OR REPLACE FUNCTION public.heartbeat_episode_lease(
114
+ p_thread_id uuid,
115
+ p_lease_token uuid,
116
+ p_ttl_seconds integer DEFAULT 90
117
+ )
118
+ RETURNS boolean
119
+ LANGUAGE plpgsql
120
+ SECURITY DEFINER
121
+ SET search_path = public
122
+ AS $$
123
+ BEGIN
124
+ UPDATE public.episode_leases SET
125
+ expires_at = now() + make_interval(secs => p_ttl_seconds),
126
+ updated_at = now()
127
+ WHERE thread_id = p_thread_id
128
+ AND lease_token = p_lease_token;
129
+
130
+ RETURN FOUND;
131
+ END;
132
+ $$;
133
+
134
+ REVOKE ALL ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) FROM PUBLIC;
135
+ REVOKE ALL ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) FROM anon;
136
+ REVOKE ALL ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) FROM authenticated;
137
+ GRANT EXECUTE ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) TO service_role;
138
+
139
+ -- ── release_episode_lease ──────────────────────────────────────────
140
+ -- Token-gated release on terminal state. Returns false when the token
141
+ -- no longer matches (a takeover superseded this driver) — warn-only,
142
+ -- never an error.
143
+ CREATE OR REPLACE FUNCTION public.release_episode_lease(
144
+ p_thread_id uuid,
145
+ p_lease_token uuid
146
+ )
147
+ RETURNS boolean
148
+ LANGUAGE plpgsql
149
+ SECURITY DEFINER
150
+ SET search_path = public
151
+ AS $$
152
+ BEGIN
153
+ DELETE FROM public.episode_leases
154
+ WHERE thread_id = p_thread_id
155
+ AND lease_token = p_lease_token;
156
+
157
+ RETURN FOUND;
158
+ END;
159
+ $$;
160
+
161
+ REVOKE ALL ON FUNCTION public.release_episode_lease(uuid, uuid) FROM PUBLIC;
162
+ REVOKE ALL ON FUNCTION public.release_episode_lease(uuid, uuid) FROM anon;
163
+ REVOKE ALL ON FUNCTION public.release_episode_lease(uuid, uuid) FROM authenticated;
164
+ GRANT EXECUTE ON FUNCTION public.release_episode_lease(uuid, uuid) TO service_role;
165
+
166
+ -- ── record_trigger_fire ────────────────────────────────────────────
167
+ -- Atomic trigger bookkeeping. trigger-engine previously passed an
168
+ -- adminDb.rpc() Promise as a column value inside an UPDATE payload —
169
+ -- the update failed silently, so trigger_count stayed 0 and
170
+ -- last_triggered stayed null on every fire.
171
+ CREATE OR REPLACE FUNCTION public.record_trigger_fire(
172
+ p_trigger_id uuid
173
+ )
174
+ RETURNS void
175
+ LANGUAGE sql
176
+ SECURITY DEFINER
177
+ SET search_path = public
178
+ AS $$
179
+ UPDATE public.triggers SET
180
+ trigger_count = COALESCE(trigger_count, 0) + 1,
181
+ last_triggered = now()
182
+ WHERE id = p_trigger_id;
183
+ $$;
184
+
185
+ REVOKE ALL ON FUNCTION public.record_trigger_fire(uuid) FROM PUBLIC;
186
+ REVOKE ALL ON FUNCTION public.record_trigger_fire(uuid) FROM anon;
187
+ REVOKE ALL ON FUNCTION public.record_trigger_fire(uuid) FROM authenticated;
188
+ GRANT EXECUTE ON FUNCTION public.record_trigger_fire(uuid) TO service_role;
189
+
190
+ -- CIL[DOC-VECTOR type="context" content="purpose='Atomic single-flight lease for agent episodes keyed by thread_id, plus atomic record_trigger_fire bookkeeping.'; Callers: _shared/agent-runner.ts (kickAgentEpisode, runAgentStep heartbeat, runAgent release), functions/ai-agents.ts (run handler), _shared/trigger-engine.ts (record_trigger_fire); imports: none" status="ready"]
191
+ -- CIL[DOC-VECTOR type="blast_radius" content="Any agent episode entry path now serializes drivers atomically; callers that do not claim proceed unchanged when the RPCs are absent" status="ready"]
192
+ -- CIL[DOC-VECTOR type="configuration" content="Runs after thread/message tables exist; consumed by _shared/agent-runner.ts via adminDb.rpc" status="ready"]
193
+ -- CIL[DOC-VECTOR type="constraint" content="episode_leases and all three RPCs are service-role only; heartbeat and release are fenced by lease_token; same-driver acquire renews; expired leases are taken over with a fresh token" status="ready"]
194
+ -- CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
195
+ -- CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
196
+ -- CIL[END stable_id="~/.framework/migrations/064_episode_leases.sql:episode_leases:file"]
@@ -0,0 +1,20 @@
1
+ -- CIL[BEGIN stable_id="~/.framework/migrations/065_execution_tables_updated_at.sql:execution_tables_updated_at:file" type="file" identity="execution_tables_updated_at" parent="~/.framework/migrations/065_execution_tables_updated_at.sql"]
2
+ -- CIL[DOC-MACRO purpose="Add the missing updated_at column to tables that carry the set_*_updated_at trigger but were created without the column — every UPDATE on them fails with 'record NEW has no field updated_at', which is what wedged pipeline_executions rows in 'running' forever."]
3
+ -- CIL[DOC-RESPONSIBILITY responsibility="Backfill updated_at on pipeline_executions (required for terminal bookkeeping writes) and on logs/observability (defensive — same broken trigger, though those tables are append-only today)."]
4
+ -- CIL[DOC-CONTEXT module="migrations" layer="database" related_files=".framework/migrations/001_core_schema.sql,.framework/functions/_shared/pipeline-runner.ts"]
5
+ -- CIL[DOC-CONTRACT inputs="none" outputs="three tables gain updated_at timestamptz DEFAULT now()" errors="none — idempotent ADD COLUMN IF NOT EXISTS" side_effects="Enables previously-failing UPDATEs on pipeline_executions; trigger set_*_updated_at now has a column to stamp"]
6
+ -- CIL[DOC-MICRO logic_summary="001_core_schema attached updated_at_column() triggers to tables whose CREATE TABLE omitted updated_at; the trigger raises on every UPDATE, so pipeline-runner's terminal status writes silently failed (unchecked .update()). Adding the column makes the trigger a no-op stamp instead of an error." notable_decisions="Column added to all three affected tables for uniformity rather than dropping the triggers on append-only logs/observability — keeps the bookkeeping convention consistent if either table ever becomes updateable."]
7
+ -- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ -- CIL[DOC-STABILITY stability="stable"]
9
+
10
+ ALTER TABLE public.pipeline_executions ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
11
+ ALTER TABLE public.logs ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
12
+ ALTER TABLE public.observability ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
13
+
14
+ -- CIL[DOC-VECTOR type="context" content="purpose='Fix broken updated_at triggers on execution/log tables.'; Callers: _shared/pipeline-runner.ts terminal bookkeeping; imports: none" status="ready"]
15
+ -- CIL[DOC-VECTOR type="blast_radius" content="pipeline_executions UPDATEs begin succeeding immediately; rows stuck 'running' from the silent failure remain and must be repaired separately" status="ready"]
16
+ -- CIL[DOC-VECTOR type="configuration" content="Applies to any deployment created from 001_core_schema before this fix" status="ready"]
17
+ -- CIL[DOC-VECTOR type="constraint" content="Idempotent; safe to re-run; does not repair historical stuck rows — that is a separate bookkeeping UPDATE" status="ready"]
18
+ -- CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
19
+ -- CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
20
+ -- CIL[END stable_id="~/.framework/migrations/065_execution_tables_updated_at.sql:execution_tables_updated_at:file"]
@@ -0,0 +1,148 @@
1
+ -- CIL[BEGIN stable_id="~/.framework/migrations/066_episode_steal_thread_merge.sql:episode_steal_thread_merge:file" type="file" identity="episode_steal_thread_merge" parent="~/.framework/migrations/066_episode_steal_thread_merge.sql"]
2
+ -- CIL[DOC-MACRO purpose="Episode-lease steal + fenced thread-data merge: request-bound drivers can immediately take over a thread whose lease is held by a 'detached:'-prefixed background driver, and threads.data writers get an atomic shallow-merge RPC with an optional lease-token fence so terminal research_state checkpoints cannot be clobbered by stale read-modify-write blobs."]
3
+ -- CIL[DOC-RESPONSIBILITY responsibility="Extend acquire_episode_lease with p_steal_detached so live 'detached:' lease holders yield instantly to request-bound drivers, and add merge_thread_data as the single atomic writer for threads.data patches."]
4
+ -- CIL[DOC-CONTEXT module="migrations" layer="database" related_files=".framework/functions/_shared/agent-runner.ts,.framework/functions/ai-agents.ts,.framework/migrations/064_episode_leases.sql"]
5
+ -- CIL[DOC-CONTRACT inputs="acquire: +p_steal_detached boolean; merge_thread_data: p_thread_id/p_patch/p_lease_token" outputs="acquire jsonb {outcome: claimed|held, lease_token?, holder_driver_id?}; merge_thread_data boolean" errors="none — all branches return" side_effects="Updates episode_leases / threads"]
6
+ -- CIL[DOC-MICRO logic_summary="acquire_episode_lease keeps the 064 fast-path INSERT plus FOR UPDATE conflict path; the 'held' verdict is skipped when p_steal_detached and the holder is 'detached:%' — detached background drivers exist to yield. merge_thread_data does data = coalesce(data,'{}'::jsonb) || p_patch with WHERE-fencing: when p_lease_token is provided the write only lands if no differently-tokened lease holds the thread, so a displaced driver's stale checkpoint is dropped." notable_decisions="Detached steal is safer than client-side driver_id adoption: the server decides who yields, and live (non-detached) holders are still never preempted — only TTL expiry displaces them; jsonb || is a shallow merge so siblings written concurrently (escalation_status, research_state) no longer clobber each other; the lease-token fence makes a loser's terminal write a silent no-op rather than a resurrected mid-episode state"]
7
+ -- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ -- CIL[DOC-STABILITY stability="stable"]
9
+
10
+ -- ── acquire_episode_lease: detached-holder steal ────────────────────
11
+ -- 'detached:' drivers are spawned background work (ticket-creation
12
+ -- kicks, trigger-fired episodes) — they exist so an episode survives a
13
+ -- dead client, not to block one. On serverless platforms detached work
14
+ -- is CPU-throttled after the response flush (observed ~37s dead window
15
+ -- before its first persisted message), so a request-bound driver must
16
+ -- be able to take over immediately instead of watching a stalled
17
+ -- driver or waiting out the TTL. Live non-detached holders are never
18
+ -- preempted — only TTL expiry displaces them, exactly as in 064.
19
+ DROP FUNCTION IF EXISTS public.acquire_episode_lease(uuid, uuid, text, integer);
20
+ CREATE FUNCTION public.acquire_episode_lease(
21
+ p_thread_id uuid,
22
+ p_account_id uuid,
23
+ p_driver_id text,
24
+ p_ttl_seconds integer DEFAULT 90,
25
+ p_steal_detached boolean DEFAULT false
26
+ )
27
+ RETURNS jsonb
28
+ LANGUAGE plpgsql
29
+ SECURITY DEFINER
30
+ SET search_path = public
31
+ AS $$
32
+ DECLARE
33
+ v_lease public.episode_leases%ROWTYPE;
34
+ v_new_token uuid := gen_random_uuid();
35
+ BEGIN
36
+ -- Fast path: claim the empty slot. The thread_id PK serializes
37
+ -- concurrent first-writers — the loser's INSERT is a no-op.
38
+ INSERT INTO public.episode_leases (thread_id, account_id, driver_id, lease_token, expires_at)
39
+ VALUES (
40
+ p_thread_id,
41
+ p_account_id,
42
+ p_driver_id,
43
+ v_new_token,
44
+ now() + make_interval(secs => p_ttl_seconds)
45
+ )
46
+ ON CONFLICT (thread_id) DO NOTHING;
47
+
48
+ IF FOUND THEN
49
+ RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
50
+ END IF;
51
+
52
+ -- Conflict path: lock the row so a concurrent release/takeover cannot
53
+ -- interleave between our read and our verdict.
54
+ SELECT * INTO v_lease
55
+ FROM public.episode_leases
56
+ WHERE thread_id = p_thread_id
57
+ FOR UPDATE;
58
+
59
+ -- A live different driver owns the episode — refuse, unless the holder
60
+ -- is a detached background driver and the caller asked to steal it.
61
+ -- Detached drivers are a liveness fallback, not a real owner: a live
62
+ -- request driver always wins over one. The detached driver's next
63
+ -- heartbeat fails the token check and it aborts cleanly, so the steal
64
+ -- never produces duplicate side effects.
65
+ IF v_lease.driver_id <> p_driver_id
66
+ AND v_lease.expires_at > now()
67
+ AND NOT (p_steal_detached AND v_lease.driver_id LIKE 'detached:%') THEN
68
+ RETURN jsonb_build_object(
69
+ 'outcome', 'held',
70
+ 'holder_driver_id', v_lease.driver_id
71
+ );
72
+ END IF;
73
+
74
+ -- Same driver (renew), expired lease (takeover), or detached steal:
75
+ -- fresh token + TTL. A displaced driver's later heartbeat/release
76
+ -- fails the token check, which is what fences its pending writes.
77
+ UPDATE public.episode_leases SET
78
+ driver_id = p_driver_id,
79
+ lease_token = v_new_token,
80
+ account_id = p_account_id,
81
+ expires_at = now() + make_interval(secs => p_ttl_seconds),
82
+ updated_at = now()
83
+ WHERE thread_id = p_thread_id;
84
+
85
+ RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
86
+ END;
87
+ $$;
88
+
89
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) FROM PUBLIC;
90
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) FROM anon;
91
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) FROM authenticated;
92
+ GRANT EXECUTE ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) TO service_role;
93
+
94
+ -- ── merge_thread_data ──────────────────────────────────────────────
95
+ -- Atomic shallow merge into threads.data. The previous pattern —
96
+ -- read threads.data, merge in memory, write the whole blob back —
97
+ -- loses any key written between the read and the write; during the
98
+ -- guided demo a concurrent threads.data write resurrected a stale
99
+ -- research_state over the terminal 'complete' checkpoint, leaving the
100
+ -- thread showing planning/turn 0 after the final answer landed.
101
+ -- jsonb || merges keys atomically so sibling writes never clobber.
102
+ --
103
+ -- Lease fence: when p_lease_token is provided, the write only lands
104
+ -- while no differently-tokened episode_lease holds the thread — a
105
+ -- displaced driver's stale checkpoint becomes a no-op instead of
106
+ -- resurrecting mid-episode state over the takeover owner's terminal
107
+ -- write. Pass NULL (or omit) for writers with no lease identity; the
108
+ -- merge is still atomic, just unfenced.
109
+ --
110
+ -- Returns true when the row was written, false when fenced or the
111
+ -- thread does not exist.
112
+ CREATE OR REPLACE FUNCTION public.merge_thread_data(
113
+ p_thread_id uuid,
114
+ p_patch jsonb,
115
+ p_lease_token uuid DEFAULT null
116
+ )
117
+ RETURNS boolean
118
+ LANGUAGE plpgsql
119
+ SECURITY DEFINER
120
+ SET search_path = public
121
+ AS $$
122
+ BEGIN
123
+ UPDATE public.threads t SET
124
+ data = coalesce(t.data, '{}'::jsonb) || p_patch,
125
+ updated_at = now()
126
+ WHERE t.id = p_thread_id
127
+ AND (p_lease_token IS NULL OR NOT EXISTS (
128
+ SELECT 1 FROM public.episode_leases l
129
+ WHERE l.thread_id = p_thread_id
130
+ AND l.lease_token IS DISTINCT FROM p_lease_token
131
+ ));
132
+
133
+ RETURN FOUND;
134
+ END;
135
+ $$;
136
+
137
+ REVOKE ALL ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) FROM PUBLIC;
138
+ REVOKE ALL ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) FROM anon;
139
+ REVOKE ALL ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) FROM authenticated;
140
+ GRANT EXECUTE ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) TO service_role;
141
+
142
+ -- CIL[DOC-VECTOR type="context" content="purpose='Detached-driver lease steal plus atomic fenced threads.data merge.'; Callers: _shared/agent-runner.ts (acquireEpisodeLease, mergeThreadData, setResearchState, handleEscalation, claimAgentEpisode/kickAgentEpisode), functions/ai-agents.ts (run handler); imports: none" status="ready"]
143
+ -- CIL[DOC-VECTOR type="blast_radius" content="Episode entry paths can preempt detached fallback drivers instantly; all threads.data writers through merge_thread_data gain atomicity and optional lease fencing" status="ready"]
144
+ -- CIL[DOC-VECTOR type="configuration" content="Runs after 064_episode_leases.sql; consumed by _shared/agent-runner.ts via adminDb.rpc" status="ready"]
145
+ -- CIL[DOC-VECTOR type="constraint" content="service-role only; 'detached:%' driver_id prefix is a contract with kickAgentEpisode; merge_thread_data is a shallow merge — nested objects in p_patch replace wholesale" status="ready"]
146
+ -- CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
147
+ -- CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
148
+ -- CIL[END stable_id="~/.framework/migrations/066_episode_steal_thread_merge.sql:episode_steal_thread_merge:file"]
@@ -0,0 +1,93 @@
1
+ -- Migration 067: stale-gated detached-driver steal
2
+ --
3
+ -- 066 let a request-bound driver steal any live 'detached:' lease
4
+ -- unconditionally. That guaranteed a mid-turn displacement on every
5
+ -- guided run (triage kick claims first, the client's episode POST steals
6
+ -- seconds later), fencing a healthy driver and leaving research_state
7
+ -- mid-flight — the residual duplicate-narration class. Steal is a
8
+ -- recovery path for STALLED background work, so it now requires the
9
+ -- holder's heartbeat (updated_at, renewed by heartbeat_episode_lease)
10
+ -- to be older than p_stale_seconds. Healthy detached drivers are
11
+ -- refused 'held' like any other live driver.
12
+
13
+ -- CIL[BEGIN stable_id="~/.framework/migrations/067_episode_lease_stale_steal.sql:module:migration" type="migration" identity="episode-lease-stale-steal" parent="~/.framework/migrations/067_episode_lease_stale_steal.sql"]
14
+ -- CIL[DOC-MACRO purpose="Gate p_steal_detached on the holder's heartbeat age so request drivers only displace stalled background work"]
15
+ -- CIL[DOC-RESPONSIBILITY responsibility="Redefine acquire_episode_lease with a p_stale_seconds parameter; detach-prefix holders are stealable only when updated_at exceeds the staleness window"]
16
+ -- CIL[DOC-CONTEXT module="episode-concurrency" layer="schema" related_files=".framework/functions/_shared/agent-runner.ts,.framework/functions/ai-agents.ts"]
17
+ -- CIL[DOC-CONTRACT inputs="p_thread_id uuid, p_account_id uuid, p_driver_id text, p_ttl_seconds int default 90, p_steal_detached bool default false, p_stale_seconds int default 30" outputs="jsonb verdict {outcome: claimed|held, lease_token?, holder_driver_id?}" errors="none — fail-open verdict" side_effects="inserts or updates episode_leases"]
18
+ -- CIL[DOC-MICRO logic_summary="Identical to 066 except the steal branch additionally requires v_lease.updated_at to be older than p_stale_seconds; heartbeat renewals keep a healthy detached driver unstealable" notable_decisions="30s default bounds takeover latency well under the 90s TTL while tolerating slow single turns between heartbeats"]
19
+ -- CIL[DOC-CONFIDENCE pass="first" score="0.92" flags="none"]
20
+ -- CIL[DOC-STABILITY stability="stable"]
21
+ -- CIL[DOC-AUDIENCE audience="system-admin"]
22
+ -- CIL[DOC-VECTOR type="context" content="Third lease RPC revision: 063 atomic claim, 066 detached steal, 067 stale-gated steal" status="ready"]
23
+ -- CIL[END stable_id="~/.framework/migrations/067_episode_lease_stale_steal.sql:module:migration"]
24
+
25
+ DROP FUNCTION IF EXISTS public.acquire_episode_lease(uuid, uuid, text, integer, boolean);
26
+
27
+ CREATE FUNCTION public.acquire_episode_lease(
28
+ p_thread_id uuid,
29
+ p_account_id uuid,
30
+ p_driver_id text,
31
+ p_ttl_seconds integer DEFAULT 90,
32
+ p_steal_detached boolean DEFAULT false,
33
+ p_stale_seconds integer DEFAULT 30
34
+ )
35
+ RETURNS jsonb
36
+ LANGUAGE plpgsql
37
+ SECURITY DEFINER
38
+ SET search_path = public
39
+ AS $$
40
+ DECLARE
41
+ v_lease public.episode_leases%ROWTYPE;
42
+ v_new_token uuid := gen_random_uuid();
43
+ BEGIN
44
+ INSERT INTO public.episode_leases (thread_id, account_id, driver_id, lease_token, expires_at)
45
+ VALUES (
46
+ p_thread_id,
47
+ p_account_id,
48
+ p_driver_id,
49
+ v_new_token,
50
+ now() + make_interval(secs => p_ttl_seconds)
51
+ )
52
+ ON CONFLICT (thread_id) DO NOTHING;
53
+
54
+ IF FOUND THEN
55
+ RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
56
+ END IF;
57
+
58
+ SELECT * INTO v_lease
59
+ FROM public.episode_leases
60
+ WHERE thread_id = p_thread_id
61
+ FOR UPDATE;
62
+
63
+ -- Held: a different live driver owns the episode. Steal is allowed
64
+ -- only for a 'detached:'-prefixed holder whose heartbeat has gone
65
+ -- stale — a healthy background driver is refused like anyone else so
66
+ -- request claims stop displacing mid-turn work.
67
+ IF v_lease.driver_id <> p_driver_id
68
+ AND v_lease.expires_at > now()
69
+ AND NOT (p_steal_detached
70
+ AND v_lease.driver_id LIKE 'detached:%'
71
+ AND v_lease.updated_at < now() - make_interval(secs => p_stale_seconds)) THEN
72
+ RETURN jsonb_build_object(
73
+ 'outcome', 'held',
74
+ 'holder_driver_id', v_lease.driver_id
75
+ );
76
+ END IF;
77
+
78
+ UPDATE public.episode_leases SET
79
+ driver_id = p_driver_id,
80
+ lease_token = v_new_token,
81
+ account_id = p_account_id,
82
+ expires_at = now() + make_interval(secs => p_ttl_seconds),
83
+ updated_at = now()
84
+ WHERE thread_id = p_thread_id;
85
+
86
+ RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
87
+ END;
88
+ $$;
89
+
90
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) FROM PUBLIC;
91
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) FROM anon;
92
+ REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) FROM authenticated;
93
+ GRANT EXECUTE ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) TO service_role;