Agent handler_mode: internal/external/paused — robust meldingsruting

Tre moduser i agent_identities.handler_mode:
- internal: maskinrommet kjører synops-respond (eksternt API)
- external: jobb settes til 'deferred', forblir urørt for Claude Code
- paused: svar bruker med "AI utilgjengelig", marker done

Jobbkøen overskriver ikke deferred-status (sjekker result.status).
Ny job_status 'deferred' i PG enum.
Scripts: vaktmester-poll.sh (finn deferred jobber),
vaktmester-complete.sh (marker behandlet).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-20 05:57:01 +00:00
parent db7a8f4a15
commit 956fbc124c
4 changed files with 93 additions and 10 deletions

View file

@ -55,23 +55,57 @@ pub async fn handle_agent_respond(
let config = load_agent_config(db, agent_node_id).await?; let config = load_agent_config(db, agent_node_id).await?;
// --- Sikkerhetskontroller (forblir i maskinrommet) --- // --- Handler-modus og sikkerhetskontroller ---
// Kill switch // Sjekk handler_mode: internal, external, disabled
let is_active: bool = sqlx::query_scalar( let (is_active, handler_mode): (bool, String) = sqlx::query_as::<_, (bool, String)>(
"SELECT is_active FROM agent_identities WHERE node_id = $1", "SELECT is_active, handler_mode FROM agent_identities WHERE node_id = $1",
).bind(agent_node_id).fetch_optional(db).await ).bind(agent_node_id).fetch_optional(db).await
.map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(false); .map_err(|e| format!("DB-feil: {e}"))?
if !is_active { .unwrap_or((false, "disabled".to_string()));
// La jobben ligge for ekstern handler (Claude Code polling)
sqlx::query("UPDATE job_queue SET status = 'pending', started_at = NULL WHERE id = $1") // Disabled: dropp jobben
if !is_active || handler_mode == "disabled" {
return Ok(serde_json::json!({"status": "skipped", "reason": "agent_disabled"}));
}
// External: la jobben ligge for ekstern handler (Claude Code / synops-agent)
if handler_mode == "external" {
sqlx::query("UPDATE job_queue SET status = 'deferred'::job_status, started_at = NULL WHERE id = $1")
.bind(job.id) .bind(job.id)
.execute(db) .execute(db)
.await .await
.map_err(|e| format!("DB-feil ved tilbakestilling: {e}"))?; .map_err(|e| format!("DB-feil ved tilbakestilling: {e}"))?;
return Ok(serde_json::json!({"status": "deferred", "reason": "agent_inactive_external_handler"})); tracing::info!(
job_id = %job.id,
"agent_respond deferred til ekstern handler (handler_mode=external)"
);
return Ok(serde_json::json!({"status": "deferred", "reason": "external_handler"}));
} }
// Paused: svar brukeren at AI er utilgjengelig
if handler_mode == "paused" {
let pause_msg = "AI-assistenten er midlertidig utilgjengelig. Meldingen din er registrert.";
// Skriv svar i chatten
sqlx::query(
"WITH new_node AS (
INSERT INTO nodes (id, node_kind, content, visibility, created_by)
VALUES (gen_random_uuid(), 'content', $1, 'hidden', $2)
RETURNING id
)
INSERT INTO edges (id, source_id, target_id, edge_type)
SELECT gen_random_uuid(), id, $3, 'belongs_to' FROM new_node"
)
.bind(pause_msg)
.bind(agent_node_id)
.bind(communication_id)
.execute(db).await
.map_err(|e| format!("DB-feil ved pause-svar: {e}"))?;
return Ok(serde_json::json!({"status": "paused", "reason": "agent_paused"}));
}
// Internal: fortsett med synops-respond (under)
// Rate limiting // Rate limiting
let count: i64 = sqlx::query_scalar::<_, Option<i64>>( let count: i64 = sqlx::query_scalar::<_, Option<i64>>(
"SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '1 hour'", "SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '1 hour'",

View file

@ -882,7 +882,11 @@ pub fn start_worker(
match result { match result {
Ok(Ok(res)) => { Ok(Ok(res)) => {
if let Err(e) = complete_job(&db2, job.id, res).await { // Deferred-jobber eies av ekstern handler — ikke marker completed
let is_deferred = res.get("status").and_then(|s| s.as_str()) == Some("deferred");
if is_deferred {
tracing::info!(job_id = %job.id, "Jobb deferred til ekstern handler");
} else if let Err(e) = complete_job(&db2, job.id, res).await {
tracing::error!(job_id = %job.id, error = %e, "Kunne ikke markere jobb som fullført"); tracing::error!(job_id = %job.id, error = %e, "Kunne ikke markere jobb som fullført");
} else { } else {
tracing::info!(job_id = %job.id, "Jobb fullført"); tracing::info!(job_id = %job.id, "Jobb fullført");

13
scripts/vaktmester-complete.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Marker en deferred jobb som completed etter at Claude Code har svart.
# Bruk: ./scripts/vaktmester-complete.sh <job-id>
set -euo pipefail
JOB_ID="${1:?Mangler job-id}"
docker exec sidelinja-postgres-1 psql -U sidelinja -d synops -q -c "
UPDATE job_queue SET status = 'completed', completed_at = now(),
result = '{\"status\":\"completed\",\"handler\":\"claude-code\"}'::jsonb
WHERE id = '$JOB_ID';
"
echo "Jobb $JOB_ID markert som completed"

32
scripts/vaktmester-poll.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Poll for deferred agent_respond-jobber og returner info for Claude Code.
# Brukes av Claude Code sin cron-loop.
#
# Output: JSON med jobb-info hvis det finnes en jobb, ellers "none"
set -euo pipefail
CHAT_ID="abe2edfd-986b-45ba-8c2e-4461a8a7e480"
# Hent eldste deferred jobb
JOB=$(docker exec sidelinja-postgres-1 psql -U sidelinja -d synops -t -A -c "
SELECT row_to_json(t) FROM (
SELECT j.id as job_id, j.payload, j.created_at,
n.content as message,
n.created_by as sender_id,
(SELECT title FROM nodes WHERE id = n.created_by) as sender_name
FROM job_queue j
JOIN nodes n ON n.id = (j.payload->>'message_id')::uuid
WHERE j.job_type = 'agent_respond'
AND j.status = 'deferred'
ORDER BY j.created_at ASC
LIMIT 1
) t;
" 2>/dev/null)
if [ -z "$JOB" ] || [ "$JOB" = "" ]; then
echo "none"
exit 0
fi
echo "$JOB"