Fix session resume and PSD container configuration

This commit is contained in:
2026-07-20 20:22:47 +02:00
parent ec9b12dff4
commit 0cf09777f2
17 changed files with 486 additions and 22 deletions
@@ -245,3 +245,61 @@ test("reviewer_schema_linking returns a textResult (not a throw) when a proposed
_handler = null;
}
});
test("reviewer_schema_linking explains that phase four stays open when joins are missing", async () => {
_handler = (_file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
if (args[0] === "schema" && args[1] === "columns")
return JSON.stringify({ ...CATALOG, table: args[2] });
if (args[0] === "phase" && args[1] === "advance") {
const error = new Error("phase advance failed");
error.stderr = "Fase 4: più tabelle promosse richiedono join strutturati";
throw error;
}
return "";
};
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd-open-f4";
gate.default(pi);
ctx.ui.input = async (title) => {
const descriptor = JSON.parse(title);
return JSON.stringify({
id: descriptor.id,
kind: "schema-linking",
tables: descriptor.tables.map((table) => ({
id: table.id,
enacted: true,
columns: ["cod_paz"],
})),
});
};
const result = await tools.get("reviewer_schema_linking").def.execute(
"call-open-f4",
{
session: "s1",
title: "Schema linking",
tables: [
{ id: "fact", name: "fact_event", kind: "promote", suggested_columns: ["cod_paz"] },
{ id: "patient", name: "dim_patient", kind: "promote", suggested_columns: ["cod_paz"] },
],
advance: true,
},
null,
null,
ctx,
);
assert.match(result.content[0].text, /Fase resta aperta/i);
assert.match(result.content[0].text, /join/i);
} finally {
_handler = null;
}
});
@@ -0,0 +1,53 @@
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
test("writing reviewed structured joins closes phase four", async () => {
const calls = [];
const original = cp.execFileSync;
cp.execFileSync = (_file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
return "";
};
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd";
gate.default(pi);
const result = await tools.get("write_schema_linking").def.execute(
"call-write-linking",
{
session: "s1",
schema_linking: {
question: "q",
candidates: [
{ kind: "table", name: "fact_event", decision: "promoted" },
{ kind: "table", name: "dim_patient", decision: "promoted" },
],
joins: [{ from: "fact_event.cod_paz", to: "dim_patient.cod_paz" }],
},
},
null,
null,
ctx,
);
assert.ok(calls.includes("session set-schema-linking s1 --file -"));
assert.ok(calls.includes("phase advance --session s1"));
assert.match(result.content[0].text, /Fase avanzata automaticamente/);
} finally {
cp.execFileSync = original;
}
});
+8 -3
View File
@@ -1117,6 +1117,7 @@ export default function (pi) {
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
const parts = [`Schema linking registrato dal reviewer (${n} tabelle + colonne curate). schema_linking.json scritto.`];
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
else if (advance && adv.error) parts.push(`Fase resta aperta: ${adv.error}`);
return textResult(parts.join(" "));
} catch (fatal) {
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
@@ -1636,9 +1637,13 @@ export default function (pi) {
["session", "set-schema-linking", session, "--file", "-"],
JSON.stringify(schema_linking),
);
return textResult(
`schema_linking.json scritto e validato per la sessione ${session}.`,
);
const message = `schema_linking.json scritto e validato per la sessione ${session}.`;
if (currentPhase(ctx, session) !== 4) return textResult(message);
const advanced = forceAdvance(ctx, session);
if (advanced.error) {
return textResult(`${message} Fase non avanzata: ${advanced.error}`);
}
return textResult(`${message} Fase avanzata automaticamente.`);
} catch (e) {
const cliMsg = (e.stderr || e.message || String(e)).toString().trim();
return textResult(`${cliMsg} Correggi schema_linking e riprova.`);
+12 -7
View File
@@ -31,9 +31,10 @@ closes the phase itself; a `reviewer_confirm kind:"phase"` summary gate exists o
completeness is a human judgment (F1, F2 with recorded memories, F5). A `reviewer_decide`/
`reviewer_select` choice records its OWN decision but does NOT advance the phase. `advance:true`
on `reviewer_decide` auto-advances only F2 (empty memory) and F6 (skipped/empty) — never a
phase that recorded substantive decisions. FIVE gates close their phase themselves, because
there the human interaction IS the phase approval: `rewrite_question` (F3),
`reviewer_schema_linking` with `advance:true` (F4), the LAST `reviewer_confirm
phase that recorded substantive decisions. FIVE phase-completion mechanisms close their phase
themselves, because there the human interaction IS the phase approval: `rewrite_question` (F3),
the final F4 schema persistence (`reviewer_schema_linking` for a single-table plan, otherwise
`write_schema_linking` after the join review), the LAST `reviewer_confirm
kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and
`reviewer_memory_promote` (F8).
@@ -42,7 +43,7 @@ kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and
| F1 chiarimento | — | `reviewer_confirm kind:"phase"` |
| F2 memoria | — | `advance:true` only if nothing recorded; else `reviewer_confirm kind:"phase"` |
| F3 riscrittura | `question.md` | `rewrite_question` records approval and advances automatically |
| F4 schema_linking | `schema_linking.json` | `reviewer_schema_linking` with `advance:true` closes the phase itself (the curation IS the approval; `reviewer_confirm kind:"phase"` only as fallback if it reports an error). Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. |
| F4 schema_linking | `schema_linking.json` | `reviewer_schema_linking(advance:true)` closes a single-table plan. With multiple promoted tables it deliberately keeps F4 open until the separate join review is persisted into `schema_linking.json`; the succeeding `write_schema_linking` closes F4 automatically. Never add `reviewer_confirm kind:"phase"`. Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. |
| F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) |
| F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE with `kind:"cte_result"`; approving the LAST CTE of the plan closes the phase automatically (`kind:"phase"` only as fallback if the auto-close reports an error) |
| F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved` AND closes the phase automatically (`kind:"phase"` only as fallback if it reports an error) |
@@ -60,8 +61,8 @@ kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and
with the deliberate `reviewer_confirm kind:"phase"` summary gate. The `advance:true`
flag on `reviewer_decide` is a shortcut that auto-advances ONLY F2 when the memory phase
recorded nothing and F6 when it is skipped/empty; everywhere else it is a silent no-op,
so never rely on it to advance. The self-closing gates are the five listed above
(F3 `rewrite_question`, F4 `reviewer_schema_linking` `advance:true`, F6 last
so never rely on it to advance. The self-closing mechanisms are the five listed above
(F3 `rewrite_question`, F4 final schema persistence, F6 last
`kind:"cte_result"`, F7 `kind:"sql"`, F8 `reviewer_memory_promote`) — after one of
those, do NOT add a `reviewer_confirm kind:"phase"` that merely echoes it; the phase is
already closed.
@@ -286,6 +287,9 @@ Prerequisite: Phase 3 closed.
columns: project exactly those in the final SELECT (Phase 6/7); you remain free
to reference other columns as join keys or filter predicates when the query
requires them.
Call `reviewer_schema_linking` before the join review. For a multi-table plan,
`advance:true` will report that F4 remains open because the structured joins are
not present yet; this is expected. Stay in F4 and continue with the join-only gate.
Propose **all required joins together in a separate, join-only**
`reviewer_decide(advance:false)`, registering `join_modified`. Do not mix
`join_modified` with other decision types in that call. The gate renders this proposal
@@ -317,7 +321,8 @@ Prerequisite: Phase 3 closed.
excluded:[...], open_questions:[], concept_formulas:[]}` — `candidates`/`excluded`
are owned by `reviewer_schema_linking`/`sync-schema-linking` (step 2), so if you
call `write_schema_linking` after step 2, carry over its `candidates`/`excluded`
unchanged rather than overwriting them. Then close with `reviewer_confirm
unchanged rather than overwriting them. After the reviewer-approved joins are present,
`write_schema_linking` closes F4 automatically. Do not add a `reviewer_confirm
kind:"phase"`. Do NOT run `tht session check` (that's Phase 5).
## Phase 5 — Synthesis
@@ -0,0 +1,20 @@
import os
from importlib import reload
from pathlib import Path
def test_cli_default_config_honors_tht_config(monkeypatch):
from tht.cli import config_cmd
previous = os.environ.get("THT_CONFIG")
try:
monkeypatch.setenv("THT_CONFIG", "/app/harness/config/tht.yaml")
reload(config_cmd)
assert config_cmd.CONFIG_OPT.default == Path("/app/harness/config/tht.yaml")
finally:
if previous is None:
monkeypatch.delenv("THT_CONFIG", raising=False)
else:
monkeypatch.setenv("THT_CONFIG", previous)
reload(config_cmd)
@@ -0,0 +1,63 @@
import json
from tht.decisions import append_decision
from tht.phase import advance_problems
def _phase_four_session(tmp_path):
session = tmp_path / "session"
session.mkdir()
for phase in range(1, 4):
append_decision(session, type="phase_approved", subject=f"phase:{phase}")
return session
def test_phase_four_cannot_close_multiple_tables_without_reviewed_structured_joins(tmp_path):
session = _phase_four_session(tmp_path)
(session / "schema_linking.json").write_text(json.dumps({
"question": "q",
"candidates": [
{"kind": "table", "name": "fact_event", "decision": "promoted"},
{"kind": "table", "name": "dim_patient", "decision": "promoted"},
],
"joins": [],
}))
problems = advance_problems(session, 4)
assert any("join" in problem.lower() for problem in problems)
def test_phase_four_allows_a_single_promoted_table_without_joins(tmp_path):
session = _phase_four_session(tmp_path)
(session / "schema_linking.json").write_text(json.dumps({
"question": "q",
"candidates": [
{"kind": "table", "name": "dim_patient", "decision": "promoted"},
],
"joins": [],
}))
assert advance_problems(session, 4) == []
def test_phase_four_allows_reviewed_structured_joins_for_multiple_tables(tmp_path):
session = _phase_four_session(tmp_path)
append_decision(
session,
type="join_modified",
subject="fact_event_to_patient",
detail="fact_event.cod_paz = dim_patient.cod_paz",
)
(session / "schema_linking.json").write_text(json.dumps({
"question": "q",
"candidates": [
{"kind": "table", "name": "fact_event", "decision": "promoted"},
{"kind": "table", "name": "dim_patient", "decision": "promoted"},
],
"joins": [
{"from": "fact_event.cod_paz", "to": "dim_patient.cod_paz"},
],
}))
assert advance_problems(session, 4) == []
@@ -0,0 +1,124 @@
from pathlib import Path
import shutil
import subprocess
import yaml
class ComposeLoader(yaml.SafeLoader):
pass
def _compose_override(loader, node):
if isinstance(node, yaml.MappingNode):
return loader.construct_mapping(node)
return loader.construct_sequence(node)
ComposeLoader.add_constructor("!override", _compose_override)
def test_psd_overlay_uses_generated_workspace_for_default_and_named_commands():
root = Path(__file__).resolve().parents[2]
compose = yaml.load(
(root / "deploy/compose.psd-local.yaml.example").read_text(),
Loader=ComposeLoader,
)
core = compose["services"]["core"]
assert core["environment"]["THT_CONFIG"] == "/app/harness/config/tht.yaml"
assert core["environment"]["THT_SECRETS_FILE"] == "/run/secrets/thothii.secrets"
for name in (
"THT_DB_NAME", "THT_DWH_REST_URL", "THT_VEC_REST_URL",
"THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL", "THT_PROFILE",
"PI_PROVIDER", "PI_MODEL", "PI_THINKING",
):
assert name in core["environment"]
def target(volume):
if isinstance(volume, dict):
return volume["target"]
parts = volume.rsplit(":", 2)
return parts[-2] if parts[-1] in {"ro", "rw"} else parts[-1]
targets = {target(volume) for volume in core["volumes"]}
assert "/app/harness/config/tht.yaml" in targets
assert "/app/harness/workspaces/psd.yaml" not in targets
assert "/data/workspaces/psd/config/tht.yaml" not in targets
assert "/data" in targets
assert "/home/thoth/.pi" in targets
assert "/home/thoth/.pi/agent/models.json" in targets
assert "/home/thoth/.pi/agent/settings.json" in targets
assert "/data/evidence" in targets
assert "/run/secrets/thothii.secrets" in targets
assert set(compose["volumes"]) == {"thoth_data", "thoth_pi_config"}
assert core["networks"]["default"]["aliases"] == ["core", "thothii-core"]
assert compose["networks"]["default"] == {
"external": True,
"name": "thothii_default",
}
def test_core_image_prepares_the_writable_pi_profile_before_mounting_config_files():
root = Path(__file__).resolve().parents[2]
dockerfile = (root / "docker/core.Dockerfile").read_text()
assert "mkdir -p /home/thoth/.pi/agent" in dockerfile
assert "chown -R thoth:thoth /home/thoth/.pi" in dockerfile
assert (
"cp --remove-destination /app/harness/workspaces/local.yaml "
"/app/harness/config/tht.yaml" in dockerfile
)
assert "ln -sf /app/harness/workspaces/local.yaml /app/harness/config/tht.yaml" not in dockerfile
assert (
"ln -sfn /app/harness/config/tht.yaml /app/harness/workspaces/psd.yaml"
in dockerfile
)
def test_psd_bootstrap_materializes_the_base_compose_env_file(tmp_path):
source_root = Path(__file__).resolve().parents[2]
root = tmp_path / "ThothII"
(root / "scripts").mkdir(parents=True)
(root / "deploy/workspaces").mkdir(parents=True)
shutil.copy(
source_root / "scripts/bootstrap-local-psd-docker-config.sh",
root / "scripts/bootstrap-local-psd-docker-config.sh",
)
shutil.copy(
source_root / "deploy/compose.psd-local.yaml.example",
root / "deploy/compose.psd-local.yaml.example",
)
shutil.copy(
source_root / "deploy/workspaces/psd.yaml.example",
root / "deploy/workspaces/psd.yaml.example",
)
source_env = tmp_path / "source.env"
source_env.write_text("\n".join([
"THT_DB_NAME=postgres",
"THT_DWH_REST_URL=https://dwh.invalid/",
"THT_VEC_REST_URL=https://vec.invalid/read/",
"THT_VEC_WRITE_REST_URL=https://vec.invalid/write/",
"THT_DWH_API_KEY=dwh",
"THT_VEC_API_KEY=reader",
"THT_VEC_WRITE_API_KEY=writer",
"",
]))
workspace = tmp_path / "workspace"
workspace.mkdir()
auth = tmp_path / "auth.json"
auth.write_text('{"zai":{"key":"model"}}')
subprocess.run(
[
"sh", str(root / "scripts/bootstrap-local-psd-docker-config.sh"),
str(source_env), str(workspace), str(auth),
],
check=True,
capture_output=True,
text=True,
)
assert (root / "deploy/thothii.env").is_file()
assert "THT_SECRETS_FILE=./deploy/secrets/thothii.secrets" in (
root / ".env"
).read_text()
+5 -1
View File
@@ -1,3 +1,4 @@
import os
from pathlib import Path
import typer
@@ -7,7 +8,10 @@ from tht.config import ConfigError, load_config
config_app = typer.Typer(help="Gestione configurazione")
CONFIG_OPT = typer.Option(
Path("config/tht.yaml"), "--config", "-c", help="Percorso del file di configurazione."
Path(os.environ.get("THT_CONFIG", "config/tht.yaml")),
"--config",
"-c",
help="Percorso del file di configurazione.",
)
+22
View File
@@ -183,6 +183,28 @@ def advance_problems(source: Path | SessionSnapshot, phase: int) -> list[str]:
l'evaluator generico (F2 pieno) entra in un secondo momento.
"""
problems: list[str] = []
if phase == 4:
raw = _artifact(source, "schema_linking", "schema_linking.json")
if raw is None:
problems.append("schema_linking.json assente (Fase 4)")
else:
try:
linking = SchemaLinking.model_validate(json.loads(raw))
except (json.JSONDecodeError, ValidationError) as e:
problems.append(f"schema_linking.json non valido (Fase 4): {e}")
else:
promoted_tables = {
candidate.name
for candidate in linking.candidates
if candidate.kind == "table" and candidate.decision == "promoted"
}
if len(promoted_tables) > 1 and (
not linking.joins or not _has_decision(source, "join_modified")
):
problems.append(
"Fase 4: più tabelle promosse richiedono join strutturati in "
"schema_linking.json e una decisione join_modified del reviewer"
)
if phase == 3 and not _has_decision(source, "question_rewritten"):
problems.append("manca la decisione question_rewritten (Fase 3)")
if phase == 5: