Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d1d68e406 | ||
|
|
6871e36657 |
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity")
|
||||
statusAddr := flag.String("status-addr", ":8090", "internal status HTTP address")
|
||||
staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered")
|
||||
leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "executor lease duration")
|
||||
pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval")
|
||||
@@ -47,6 +48,11 @@ func main() {
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "agent execution bridge is not configured; dispatch claims are disabled; recovery-only mode")
|
||||
}
|
||||
go func() {
|
||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil)); serveErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr)
|
||||
}
|
||||
}()
|
||||
ctx := context.Background()
|
||||
for {
|
||||
var result application.WorkerCycleResult
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type workerStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
ExecutorConfigured bool `json:"executor_configured"`
|
||||
Mode string `json:"mode"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func workerStatusHandler(executorConfigured bool) http.Handler {
|
||||
status := workerStatusResponse{
|
||||
Runtime: "agent-worker",
|
||||
ExecutorConfigured: executorConfigured,
|
||||
Mode: "recovery-only",
|
||||
Ready: false,
|
||||
Reason: "AGENT_EXECUTION_BRIDGE_URL is not configured",
|
||||
}
|
||||
if executorConfigured {
|
||||
status.Mode = "execution"
|
||||
status.Ready = true
|
||||
status.Reason = "typed agent execution bridge configured"
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || (r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/status") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/readyz" && !status.Ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || value.Ready || value.Mode != "recovery-only" || value.ExecutorConfigured {
|
||||
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerReadyzRequiresExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readyz status=%d", response.Code)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
workerStatusHandler(true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("configured readyz status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ func main() {
|
||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
||||
&http.Client{Timeout: 10 * time.Second},
|
||||
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -33,13 +33,14 @@ const (
|
||||
var staticFiles embed.FS
|
||||
|
||||
type gateway struct {
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
build buildMetadata
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
agentWorker *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
build buildMetadata
|
||||
}
|
||||
|
||||
type buildMetadata struct {
|
||||
@@ -76,6 +77,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentWorkerURL := "http://agent-worker:8090"
|
||||
if len(cognitiveURLs) > 1 {
|
||||
agentWorkerURL = cognitiveURLs[1]
|
||||
}
|
||||
agentWorker, err := parseRuntimeURL(agentWorkerURL, "Agent worker")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load embedded console: %w", err)
|
||||
@@ -96,7 +105,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
|
||||
if len(revision) > 12 {
|
||||
revision = revision[:12]
|
||||
}
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, agentWorker: agentWorker, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil
|
||||
}
|
||||
|
||||
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
|
||||
@@ -130,6 +139,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleFleetReadiness(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/agent-worker/status" {
|
||||
g.handleAgentWorkerStatus(w, r)
|
||||
return
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -290,6 +303,44 @@ func (g *gateway) handleFleetReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
|
||||
}
|
||||
|
||||
func (g *gateway) handleAgentWorkerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
|
||||
if status == 0 {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
target := *g.agentWorker
|
||||
target.Path = "/status"
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
var browserRequest browserAdvisoryRequest
|
||||
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) {
|
||||
|
||||
@@ -42,6 +42,39 @@ func TestCreatorRouteAllowsOnlyBoundedDelegationReads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWorkerStatusProxyRequiresCreatorSession(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
|
||||
}))
|
||||
defer core.Close()
|
||||
worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/status" {
|
||||
t.Fatalf("unexpected worker path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"runtime":"agent-worker","executor_configured":false,"mode":"recovery-only","ready":false,"reason":"bridge missing"}`)
|
||||
}))
|
||||
defer worker.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), "http://cognitive.invalid", worker.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := httptest.NewRecorder()
|
||||
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil))
|
||||
if denied.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status=%d", denied.Code)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil)
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"mode":"recovery-only"`) || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessRequiresCreatorSessionAndProxiesCognitiveState(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
|
||||
@@ -281,6 +281,11 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
|
||||
.agent-details span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agent-details strong { display: block; margin-top: 7px; overflow: hidden; color: var(--ink); font: 600 10px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-details p { grid-column: 1 / -1; margin: 0; padding-top: 12px; border-top: 1px solid var(--line-soft); color: var(--muted); font: 10px/1.5 var(--mono); }
|
||||
.agent-worker-status { margin: 0 18px 16px; padding-top: 13px; display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; border-top: 1px solid var(--line-soft); }
|
||||
.agent-worker-status span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agent-worker-status strong { color: var(--warn); font: 600 10px/1.3 var(--mono); }
|
||||
.agent-worker-status strong[data-state="ready"] { color: var(--good); }
|
||||
.agent-worker-status p { grid-column: 1 / -1; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
|
||||
|
||||
.learning-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.learning-grid article { min-width: 0; border-right: 1px solid var(--line-soft); }
|
||||
|
||||
@@ -174,6 +174,7 @@ async function showConsole() {
|
||||
loadLearning();
|
||||
loadFleetReadiness();
|
||||
loadAgentDelegation();
|
||||
loadAgentWorkerStatus();
|
||||
}
|
||||
runDiagnostics();
|
||||
}
|
||||
@@ -448,6 +449,7 @@ async function loadDashboard() {
|
||||
work.push(loadLearning());
|
||||
work.push(loadFleetReadiness());
|
||||
work.push(loadAgentDelegation());
|
||||
work.push(loadAgentWorkerStatus());
|
||||
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
|
||||
else setSignal("capability", "не выбрана", "idle");
|
||||
|
||||
@@ -580,6 +582,19 @@ async function loadAgentDelegation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentWorkerStatus() {
|
||||
try {
|
||||
const status = await request("/api/agent-worker/status");
|
||||
setText("agent-worker-state", status.mode || "UNKNOWN");
|
||||
setText("agent-worker-message", status.reason || "Причина не указана.");
|
||||
byId("agent-worker-state").dataset.state = status.ready === true ? "ready" : "degraded";
|
||||
} catch (error) {
|
||||
setText("agent-worker-state", "НЕДОСТУПЕН");
|
||||
setText("agent-worker-message", safeClientError(error));
|
||||
byId("agent-worker-state").dataset.state = "degraded";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLearning() {
|
||||
const experienceList = byId("experience-list");
|
||||
const reflectionList = byId("reflection-list");
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
<div><span>Последний blocker</span><strong id="agent-blocker">—</strong></div>
|
||||
<p id="agent-next-step">—</p>
|
||||
</div>
|
||||
<div class="agent-worker-status"><span>Executor capability</span><strong id="agent-worker-state">ПРОВЕРКА НЕ ВЫПОЛНЯЛАСЬ</strong><p id="agent-worker-message">Статус worker виден только в авторизованной диагностике.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-labelledby="activity-title" data-page="operations">
|
||||
|
||||
@@ -105,7 +105,7 @@ services:
|
||||
agent-worker:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: agent-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
command: ["-worker-id", "agent-worker-production"]
|
||||
command: ["-worker-id", "agent-worker-production", "-status-addr", ":8090"]
|
||||
environment:
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
@@ -257,6 +257,7 @@ services:
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_RUNTIME_URL: http://commit-runtime:8082
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
|
||||
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000150-agent-operations-ui.json",
|
||||
"task_id": "DEV-000150",
|
||||
"task_file": "dev/tasks/DEV-000151-agent-worker-status-ui.json",
|
||||
"task_id": "DEV-000151",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000151",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "6871e36657a10e8ad7fb1e059efdc2dde394ebbd",
|
||||
"risk_tier": "E",
|
||||
"intent": "Expose truthful agent-worker executor capability status to the authenticated operations cabinet while preserving recovery-only behavior when no bridge is configured.",
|
||||
"requirement_ids": ["WP-301", "WP-804", "INV-TOPO-001"],
|
||||
"allowed_change_scope": {"modules": ["work", "interaction", "agent-worker"], "paths": ["cmd/agent-worker/**", "cmd/creator-web/**", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000151-agent-worker-status-ui.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", ".gitea/**", "database/**", "contracts/**", "modules/**", "adapters/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["creator session", "agent-worker status HTTP response"], "may_change": []},
|
||||
"semantic_owner": "work",
|
||||
"canonical_state": [],
|
||||
"state_transitions": ["executor configured -> execution status; executor absent -> recovery-only status"],
|
||||
"side_effects_and_privileges": ["read-only internal status endpoint and authenticated UI proxy"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["status remains truthful across worker restart; no dispatch claim is introduced"],
|
||||
"assumptions": ["agent-worker and creator-web share the production compose network", "AGENT_EXECUTION_BRIDGE_URL remains the only execution activation input"],
|
||||
"acceptance_scenarios": ["worker status reports recovery-only when bridge absent", "readyz is non-ready without executor", "unauthenticated public status proxy returns 401", "authenticated cabinet renders degraded capability reason", "compose wiring reaches internal worker status"],
|
||||
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "git diff --check", "check-architecture", "independent", "docker compose config"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
Reference in New Issue
Block a user