From def0e4aff09a77ca7220d8b58aeb001d0a6def5e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 23 Aug 2026 12:18:53 +0300 Subject: [PATCH] feat(work): persist task evaluation worker context --- adapters/postgres/work_repository.go | 23 ++++++---- contracts/work/lease.go | 30 +++++++----- .../040_task_evaluation_dispatch.sql | 23 ++++++++++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000210-task-evaluator-worker.json | 23 ++++++++++ modules/work/application/graph_runtime.go | 16 ++++++- .../task_evaluation_dispatch_test.go | 46 +++++++++++++++++++ .../task_evaluation_dispatch_sql_test.go | 20 ++++++++ 8 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 database/migrations/040_task_evaluation_dispatch.sql create mode 100644 dev/tasks/DEV-000210-task-evaluator-worker.json create mode 100644 modules/work/application/task_evaluation_dispatch_test.go create mode 100644 tests/agent_execution/task_evaluation_dispatch_sql_test.go diff --git a/adapters/postgres/work_repository.go b/adapters/postgres/work_repository.go index f9a9cf5..e2c1e35 100644 --- a/adapters/postgres/work_repository.go +++ b/adapters/postgres/work_repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "time" "git.cin.su/m/Assistant/contracts/work" @@ -106,24 +107,28 @@ func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.Age return err } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis) + _, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis, intent.SpecialistPurpose, intent.TaskEvaluationProfileID, intent.TaskEvaluationBindingID, intent.TaskEvaluationCaseIDs) if err != nil { return fmt.Errorf("save dispatch intent: %w", err) } var existing work.AgentDispatchIntent - err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis) + err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis, &existing.SpecialistPurpose, &existing.TaskEvaluationProfileID, &existing.TaskEvaluationBindingID, &existing.TaskEvaluationCaseIDs) if err != nil { return fmt.Errorf("read dispatch intent: %w", err) } - if existing != intent { + if !sameDispatchIntent(existing, intent) { return ErrDispatchIntentConflict } return tx.Commit(ctx) } +func sameDispatchIntent(left, right work.AgentDispatchIntent) bool { + return left.DispatchID == right.DispatchID && left.IdempotencyKey == right.IdempotencyKey && left.DelegationID == right.DelegationID && left.RunID == right.RunID && left.ImplementationID == right.ImplementationID && left.ImplementationRevision == right.ImplementationRevision && left.SourceRevision == right.SourceRevision && left.WorktreeID == right.WorktreeID && left.VerificationPlanRef == right.VerificationPlanRef && left.CreatedAtUnixMillis == right.CreatedAtUnixMillis && left.SpecialistPurpose == right.SpecialistPurpose && left.TaskEvaluationProfileID == right.TaskEvaluationProfileID && left.TaskEvaluationBindingID == right.TaskEvaluationBindingID && slices.Equal(left.TaskEvaluationCaseIDs, right.TaskEvaluationCaseIDs) +} + func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) { var intent work.AgentDispatchIntent - err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis) + err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis, &intent.SpecialistPurpose, &intent.TaskEvaluationProfileID, &intent.TaskEvaluationBindingID, &intent.TaskEvaluationCaseIDs) if err != nil { return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err) } @@ -191,11 +196,11 @@ func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph return err } defer tx.Rollback(ctx) - if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts) VALUES($1,$2,$3,$4,$5) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts); err != nil { + if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts, graph.ParentGraphID, graph.ParentGraphRevision, graph.TriggerEvidenceRef); err != nil { return err } for _, node := range graph.Nodes { - if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts); err != nil { + if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts, node.Purpose, node.TaskProfileID, node.BindingID, node.CaseIDs); err != nil { return err } } @@ -209,17 +214,17 @@ func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) { var graph work.WorkGraph - err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts) + err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts, &graph.ParentGraphID, &graph.ParentGraphRevision, &graph.TriggerEvidenceRef) if err != nil { return work.WorkGraph{}, fmt.Errorf("load graph: %w", err) } - rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision) + rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision) if err != nil { return work.WorkGraph{}, err } for rows.Next() { var node work.GraphNode - if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts); err != nil { + if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs); err != nil { rows.Close() return work.WorkGraph{}, err } diff --git a/contracts/work/lease.go b/contracts/work/lease.go index 9193cf2..25351cd 100644 --- a/contracts/work/lease.go +++ b/contracts/work/lease.go @@ -1,6 +1,10 @@ package work -import "time" +import ( + "time" + + "git.cin.su/m/Assistant/contracts/capability" +) type AgentLeaseID string type AgentFencingToken uint64 @@ -50,14 +54,18 @@ type IndependentVerification struct { } type AgentDispatchIntent struct { - DispatchID string `json:"dispatch_id"` - IdempotencyKey string `json:"idempotency_key"` - DelegationID DelegationID `json:"delegation_id"` - RunID AgentRunID `json:"run_id"` - ImplementationID string `json:"implementation_id"` - ImplementationRevision int64 `json:"implementation_revision"` - SourceRevision string `json:"source_revision"` - WorktreeID string `json:"worktree_id"` - VerificationPlanRef string `json:"verification_plan_ref"` - CreatedAtUnixMillis int64 `json:"created_at_unix_millis"` + DispatchID string `json:"dispatch_id"` + IdempotencyKey string `json:"idempotency_key"` + DelegationID DelegationID `json:"delegation_id"` + RunID AgentRunID `json:"run_id"` + ImplementationID string `json:"implementation_id"` + ImplementationRevision int64 `json:"implementation_revision"` + SourceRevision string `json:"source_revision"` + WorktreeID string `json:"worktree_id"` + VerificationPlanRef string `json:"verification_plan_ref"` + CreatedAtUnixMillis int64 `json:"created_at_unix_millis"` + SpecialistPurpose string `json:"specialist_purpose,omitempty"` + TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"` + TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"` + TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"` } diff --git a/database/migrations/040_task_evaluation_dispatch.sql b/database/migrations/040_task_evaluation_dispatch.sql new file mode 100644 index 0000000..4f3ee66 --- /dev/null +++ b/database/migrations/040_task_evaluation_dispatch.sql @@ -0,0 +1,23 @@ +BEGIN; + +SET ROLE work_role; + +ALTER TABLE work.agent_dispatch_intents + ADD COLUMN IF NOT EXISTS specialist_purpose TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS task_evaluation_profile_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS task_evaluation_binding_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS task_evaluation_case_ids TEXT[] NOT NULL DEFAULT '{}'; + +ALTER TABLE work.graphs + ADD COLUMN IF NOT EXISTS parent_graph_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS parent_graph_revision BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS trigger_evidence_ref TEXT NOT NULL DEFAULT ''; + +ALTER TABLE work.graph_nodes + ADD COLUMN IF NOT EXISTS purpose TEXT NOT NULL DEFAULT 'GENERIC', + ADD COLUMN IF NOT EXISTS task_profile_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS binding_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS case_ids TEXT[] NOT NULL DEFAULT '{}'; + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 9e32acd..0a82b24 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000209-task-evaluator-work-graph.json", - "task_id": "DEV-000209", + "task_file": "dev/tasks/DEV-000210-task-evaluator-worker.json", + "task_id": "DEV-000210", "task_revision": 1 } diff --git a/dev/tasks/DEV-000210-task-evaluator-worker.json b/dev/tasks/DEV-000210-task-evaluator-worker.json new file mode 100644 index 0000000..cf74416 --- /dev/null +++ b/dev/tasks/DEV-000210-task-evaluator-worker.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000210", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "9a6ab6a9caf68e65f302c95ab0859b259122dacf", + "risk_tier": "E", + "intent": "Carry task-evaluation specialist metadata through durable dispatch, persistence and recovery so workers can execute bounded model cases and return evidence without losing profile lineage.", + "requirement_ids": ["worker execution context", "durable task metadata", "recovery-safe dispatch", "bounded case set", "no synchronous chat blocking"], + "allowed_change_scope": {"modules": ["work"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "database/migrations/040_task_evaluation_dispatch.sql", "tests/model_qualification/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000210-task-evaluator-worker.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["Work Graph task-evaluation node metadata", "AgentExecutionRequest/Result", "existing lease and recovery contract"], "may_change": ["typed dispatch task-evaluation metadata"]}, + "semantic_owner": "work", + "canonical_state": ["work-owned dispatch metadata and graph lineage; capability remains owner of evaluation evidence"], + "state_transitions": ["graph specialist node -> enriched dispatch intent", "claimed dispatch -> executor receives profile/binding/cases", "stale claim -> recovery preserves task metadata for retry"], + "side_effects_and_privileges": ["work role migration only; no provider calls and no secret access added"], + "persistence_and_migrations": ["040 adds typed task metadata columns to existing work dispatch/graph tables"], + "failure_retry_restart": ["metadata is persisted before enqueue; idempotency conflict rejects divergent task context", "lease timeout/recovery does not erase profile or case references"], + "assumptions": ["an upcoming cognitive specialist executor uses the typed metadata to invoke the model gateway and post Capability evidence", "external bridge absence remains typed WAITING/local-fallback"], + "acceptance_scenarios": ["task graph dispatch carries profile, binding and all cases", "persist/load round trip preserves metadata", "divergent idempotency retry is rejected", "legacy non-task dispatch remains valid", "recovery path does not claim success"], + "required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "migration/schema checks", "CI", "production public health check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"] +} diff --git a/modules/work/application/graph_runtime.go b/modules/work/application/graph_runtime.go index 5a00c38..204e041 100644 --- a/modules/work/application/graph_runtime.go +++ b/modules/work/application/graph_runtime.go @@ -120,7 +120,21 @@ func AdmitAndScheduleTaskEvaluation(ctx context.Context, store ports.GraphStore, if err != nil { return work.WorkGraph{}, nil, err } - return AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: request.Intents, Now: request.Now}) + intents := make([]work.AgentDispatchIntent, len(request.Intents)) + copy(intents, request.Intents) + for i := range intents { + for _, node := range graph.Nodes { + if node.DispatchID != intents[i].DispatchID { + continue + } + intents[i].SpecialistPurpose = string(node.Purpose) + intents[i].TaskEvaluationProfileID = node.TaskProfileID + intents[i].TaskEvaluationBindingID = node.BindingID + intents[i].TaskEvaluationCaseIDs = append([]string(nil), node.CaseIDs...) + break + } + } + return AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: intents, Now: request.Now}) } func CompleteGraphNode(ctx context.Context, store ports.GraphStore, graphID string, revision int64, nodeID work.GraphNodeID, success bool) (work.WorkGraph, error) { diff --git a/modules/work/application/task_evaluation_dispatch_test.go b/modules/work/application/task_evaluation_dispatch_test.go new file mode 100644 index 0000000..a9bd9fb --- /dev/null +++ b/modules/work/application/task_evaluation_dispatch_test.go @@ -0,0 +1,46 @@ +package application + +import ( + "context" + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/work" +) + +type taskGraphStore struct { + graph work.WorkGraph + intents []work.AgentDispatchIntent +} + +func (s *taskGraphStore) SaveWorkGraph(_ context.Context, graph work.WorkGraph) error { + s.graph = graph + return nil +} +func (s *taskGraphStore) LoadWorkGraph(context.Context, string, int64) (work.WorkGraph, error) { + return s.graph, nil +} +func (s *taskGraphStore) SaveDispatchIntent(_ context.Context, intent work.AgentDispatchIntent) error { + s.intents = append(s.intents, intent) + return nil +} +func (s *taskGraphStore) EnqueueDispatch(context.Context, work.AgentDispatchQueueItem) error { + return nil +} + +func TestAdmitAndScheduleTaskEvaluationCarriesSpecialistContext(t *testing.T) { + store := &taskGraphStore{} + request := TaskEvaluationScheduleRequest{ + Plan: work.TaskEvaluationGraphRequest{ + GraphID: "graph-task", Profile: capability.TaskEvaluationProfile{ID: "profile-general", Revision: 1, CapabilityID: "capability-chat", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}, + BindingIDs: []capability.CapabilityBindingID{"binding-a"}, DispatchIDs: []string{"dispatch-a"}, IdempotencyPrefix: "eval", Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 2}, + }, + Intents: []work.AgentDispatchIntent{{DispatchID: "dispatch-a", IdempotencyKey: "idem-a", DelegationID: "delegation-a", RunID: "run-a", ImplementationID: "impl", ImplementationRevision: 1, SourceRevision: "source", WorktreeID: "tree", VerificationPlanRef: "verify", CreatedAtUnixMillis: 1}}, + } + if _, _, err := AdmitAndScheduleTaskEvaluation(context.Background(), store, request); err != nil { + t.Fatal(err) + } + if len(store.intents) != 1 || store.intents[0].TaskEvaluationProfileID != "profile-general" || store.intents[0].TaskEvaluationBindingID != "binding-a" || len(store.intents[0].TaskEvaluationCaseIDs) != 2 || store.intents[0].SpecialistPurpose != string(work.GraphNodePurposeTaskEvaluation) { + t.Fatalf("specialist context was not carried into dispatch: %+v", store.intents) + } +} diff --git a/tests/agent_execution/task_evaluation_dispatch_sql_test.go b/tests/agent_execution/task_evaluation_dispatch_sql_test.go new file mode 100644 index 0000000..4880584 --- /dev/null +++ b/tests/agent_execution/task_evaluation_dispatch_sql_test.go @@ -0,0 +1,20 @@ +package agent_execution + +import ( + "os" + "strings" + "testing" +) + +func TestTaskEvaluationDispatchMigrationPersistsWorkerContext(t *testing.T) { + payload, err := os.ReadFile("../../database/migrations/040_task_evaluation_dispatch.sql") + if err != nil { + t.Fatal(err) + } + sql := strings.ToLower(string(payload)) + for _, marker := range []string{"work.agent_dispatch_intents", "specialist_purpose", "task_evaluation_profile_id", "task_evaluation_binding_id", "task_evaluation_case_ids", "work.graphs", "parent_graph_id", "work.graph_nodes", "case_ids", "set role work_role"} { + if !strings.Contains(sql, strings.ToLower(marker)) { + t.Fatalf("migration missing %q", marker) + } + } +} -- 2.54.0