Skip to content

Commit d246347

Browse files
authored
Merge branch 'main' into fix/edit-task-modal-load
2 parents 84da7bf + f4ddaaa commit d246347

34 files changed

Lines changed: 14735 additions & 10270 deletions

.agents/skills/transformerlab-cli/references/autoresearch.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ The agent loops autonomously. Never ask "should I continue?" — keep going unti
219219
--param lr=3e-5 --param warmup_steps=500 \
220220
-m "Bumped lr 1e-5→3e-5, warmup 100→500. Testing whether higher lr clears the eval/loss=2.1 plateau seen in baseline."
221221
```
222+
Immediately after queueing, add a structured log line to experiment notes so intent survives even if the session resets before `finalize`:
223+
```bash
224+
lab notes append "- $(date +%F): queued <JOB_ID> | intent: <what changed> | hypothesis: <expected metric behavior> | reason: <why this is the next best idea> | expected-risk: <what might break>"
225+
```
226+
Get `<JOB_ID>` from the queue command output or from `lab --format json job list | jq '.[-1].id'` if needed.
222227
6. **Advance.** What this means depends on parallelism:
223228
- **N = 1 (sequential):** wait for the just-queued job to finish, then go to step 7. Poll with `lab --format json job list --running`.
224229
- **N ≥ 2 (parallel / fire-and-advance):** **do not wait.** Return to step 0 and pick the next idea immediately, until `RUNNING >= N`. Only block when the queue is full. This is what enables grid-style search; waiting after every queue collapses parallelism back to 1.
@@ -234,6 +239,10 @@ The agent loops autonomously. Never ask "should I continue?" — keep going unti
234239
# If COMPLETE but primary metric did NOT improve over current best → lab job discard <JOB_ID>.
235240
# If COMPLETE and improved → leave kept. Jobs are kept by default.
236241
```
242+
After deciding keep/discard, append a one-line outcome note for each processed job:
243+
```bash
244+
lab notes append "- $(date +%F): result <JOB_ID> | <kept|discarded|failed> | <primary_metric>=<value> | delta-vs-best: <+/-> | takeaway: <what to repeat/avoid next> | next: <follow-up idea or 'none'>"
245+
```
237246
8. **Loop.**
238247

239248
The agent stops the loop only when:
@@ -351,9 +360,9 @@ Do **not** delete the experiment. Do **not** delete jobs. The session record is
351360

352361
---
353362

354-
## Run descriptions are the durable record
363+
## Run descriptions + notes are the durable record
355364

356-
Because Transformer Lab persists per-job descriptions, **`-m/--description` is the per-run log** — there is no separate `autoresearch.jsonl` to maintain. Treat each description as a mini commit message for that iteration:
365+
Because Transformer Lab persists per-job descriptions, **`-m/--description` is the per-run log** — and experiment notes are the cross-run narrative. There is no separate `autoresearch.jsonl` to maintain. Treat each description as a mini commit message for that iteration:
357366

358367
1. **What changed vs the prior best run** (params, code, infra). If nothing changed, say so.
359368
2. **What hypothesis is being tested** (why this run is worth doing).
@@ -368,14 +377,41 @@ printf '%s' "- Switched optimizer AdamW→Lion (β1=0.95, β2=0.98).
368377

369378
Bad descriptions (`"train model"`, `"another run"`) defeat the entire point — the agent two iterations from now has no idea what was tried.
370379

380+
Also mirror each queue/result in `lab notes append` during the loop (not only at finalize). Descriptions are attached to one job; notes capture session-level reasoning and make "why this order of experiments?" visible when reading the full session timeline.
381+
382+
### What to capture in `lab notes` beyond the job description
383+
384+
Use descriptions for per-job details and notes for cross-job reasoning. A good rule: if the information helps choose the *next* idea, it belongs in notes even when it is also present in `-m`.
385+
386+
- **Decision context:** Why this run was chosen over 1–2 alternatives from Backlog.
387+
- **Risk + guardrails:** What failure mode is expected and which signal confirms it.
388+
- **Comparative result:** Delta vs baseline and delta vs current best (not just raw metric).
389+
- **Actionability:** Explicit next step (`repeat`, `widen`, `revert`, `pivot`) and why.
390+
- **Session-level blockers:** Provider instability, flaky evals, or data issues affecting multiple runs.
391+
392+
Compact append examples:
393+
394+
```bash
395+
lab notes append "- $(date +%F): decision | picked JOB <JOB_ID> idea (lr+warmup) over scheduler swap because recent runs suggest under-training, not optimizer instability."
396+
lab notes append "- $(date +%F): result <JOB_ID> | kept | eval/loss=1.84 | delta-vs-baseline=-0.26 | delta-vs-best=-0.07 | next: widen around lr=3e-5."
397+
lab notes append "- $(date +%F): blocker | provider cold-start >20m on 2/3 attempts; avoid wide parallel bursts until stable."
398+
```
399+
400+
When notes get long, keep append-only in the hot loop, then periodically consolidate into:
401+
402+
1. **Key wins** (what consistently improves the primary metric)
403+
2. **Dead ends** (what reliably regresses and should not be retried)
404+
3. **Open hypotheses** (highest-value next experiments)
405+
4. **Operational caveats** (infra/eval quirks that affect interpretation)
406+
371407
---
372408

373409
## Loop discipline
374410

375411
- **Never stop the loop unless the user interrupts.** The user expects autonomous work.
376412
- **One iteration per running job slot.** Don't queue past the parallelism budget.
377413
- **Annotate failures heavily** via `-m`. Discarded ≠ deleted, and the description survives.
378-
- **Keep the experiment notes honest.** Update **What's been tried** every ~5 runs via `lab notes append`. Re-read with `lab notes show --raw` at the start of every iteration — never skip the rehydrate.
414+
- **Keep the experiment notes honest.** For every queued job, append intent/hypothesis/reason; for every processed completion, append outcome/takeaway. Continue periodic consolidation of **What's been tried** every ~5 runs. Re-read with `lab notes show --raw` at the start of every iteration — never skip the rehydrate.
379415
- **Respect provider capacity.** Local providers serialize anyway; SkyPilot/RunPod cost real money — never raise parallelism without explicit user approval.
380416
- **No `curl` workarounds.** If something seems missing from the CLI, run `lab <cmd> --help`, re-read this file, and tell the user — don't fall back to the REST API. (See the parent skill's "Do NOT call the REST API as a CLI workaround" section.)
381417

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""add_user_experiment_access_table
2+
3+
Revision ID: 46378c10f132
4+
Revises: 6ccd4a4d9ca1
5+
Create Date: 2026-05-04 13:23:27.122716
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
from transformerlab.db.migration_utils import table_exists
14+
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "46378c10f132"
18+
down_revision: Union[str, Sequence[str], None] = "6ccd4a4d9ca1"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
connection = op.get_bind()
25+
if not table_exists(connection, "user_experiment_access"):
26+
op.create_table(
27+
"user_experiment_access",
28+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
29+
sa.Column("user_id", sa.String(), nullable=False),
30+
sa.Column("team_id", sa.String(), nullable=False),
31+
sa.Column("experiment_id", sa.String(), nullable=False),
32+
sa.Column(
33+
"last_opened_at",
34+
sa.DateTime(),
35+
server_default=sa.text("(CURRENT_TIMESTAMP)"),
36+
nullable=False,
37+
),
38+
sa.PrimaryKeyConstraint("id"),
39+
sa.UniqueConstraint(
40+
"user_id",
41+
"team_id",
42+
"experiment_id",
43+
name="uq_user_experiment_access",
44+
),
45+
)
46+
op.create_index(
47+
"idx_user_experiment_access_user_team",
48+
"user_experiment_access",
49+
["user_id", "team_id"],
50+
)
51+
52+
53+
def downgrade() -> None:
54+
op.drop_index("idx_user_experiment_access_user_team", table_name="user_experiment_access", if_exists=True)
55+
op.drop_table("user_experiment_access", if_exists=True)

api/test/api/test_experiment_service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ async def test_missing_experiment_returns_none(tmp_experiments_dir):
4646
assert await experiment_service.experiment_get("no_such_experiment") is None
4747

4848

49+
@pytest.mark.asyncio
50+
async def test_duplicate_experiment_create_raises_file_exists(tmp_experiments_dir):
51+
_ = tmp_experiments_dir
52+
name = f"duplicate_exp_{uuid.uuid4().hex[:8]}"
53+
await experiment_service.experiment_create(name, {"a": 1})
54+
55+
with pytest.raises(FileExistsError):
56+
await experiment_service.experiment_create(name, {"a": 2})
57+
58+
4959
# Added test to hit the new FileNotFoundError except-clauses in experiment_service
5060
@pytest.mark.asyncio
5161
async def test_missing_experiment_operations_handle_FileNotFound(tmp_experiments_dir):
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
import pytest
5+
from fastapi import HTTPException
6+
7+
from transformerlab.routers.experiment import task as task_router
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_resolve_provider_rejects_unknown_provider_name(monkeypatch):
12+
providers = [
13+
SimpleNamespace(id="prov-default", name="AWS Default", is_default=True),
14+
SimpleNamespace(id="prov-skypilot", name="SkypilotNew", is_default=False),
15+
]
16+
monkeypatch.setattr(task_router, "list_team_providers", AsyncMock(return_value=providers))
17+
18+
task_data = {"provider_name": "Skypilot New"}
19+
20+
with pytest.raises(HTTPException) as exc:
21+
await task_router._resolve_provider(
22+
task_data=task_data,
23+
user_and_team={"team_id": "team-1"},
24+
session=MagicMock(),
25+
)
26+
27+
assert exc.value.status_code == 400
28+
assert "Unknown compute provider 'Skypilot New'" in str(exc.value.detail)
29+
assert "SkypilotNew" in str(exc.value.detail)
30+
assert "AWS Default" in str(exc.value.detail)
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_resolve_provider_uses_default_when_provider_name_missing(monkeypatch):
35+
providers = [
36+
SimpleNamespace(id="prov-default", name="AWS Default", is_default=True),
37+
SimpleNamespace(id="prov-other", name="Other Provider", is_default=False),
38+
]
39+
monkeypatch.setattr(task_router, "list_team_providers", AsyncMock(return_value=providers))
40+
41+
task_data: dict = {}
42+
await task_router._resolve_provider(
43+
task_data=task_data,
44+
user_and_team={"team_id": "team-1"},
45+
session=MagicMock(),
46+
)
47+
48+
assert task_data["provider_id"] == "prov-default"
49+
assert task_data["provider_name"] == "AWS Default"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from unittest.mock import AsyncMock, MagicMock
2+
3+
from sqlalchemy.exc import IntegrityError
4+
5+
import transformerlab.services.experiment_access_service as svc
6+
7+
8+
async def test_touch_experiment_upserts_record():
9+
mock_session = AsyncMock()
10+
mock_session.add = MagicMock()
11+
mock_result = MagicMock(rowcount=0)
12+
mock_session.execute.return_value = mock_result
13+
14+
await svc.touch_experiment(mock_session, "user1", "team1", "exp1")
15+
16+
mock_session.add.assert_called_once()
17+
mock_session.commit.assert_called_once()
18+
19+
20+
async def test_touch_experiment_updates_existing_record():
21+
mock_session = AsyncMock()
22+
mock_session.add = MagicMock()
23+
mock_result = MagicMock(rowcount=1)
24+
mock_session.execute.return_value = mock_result
25+
26+
await svc.touch_experiment(mock_session, "user1", "team1", "exp1")
27+
28+
mock_session.add.assert_not_called()
29+
mock_session.commit.assert_called_once()
30+
31+
32+
async def test_touch_experiment_handles_insert_race_integrity_error():
33+
mock_session = AsyncMock()
34+
mock_session.add = MagicMock()
35+
mock_result = MagicMock(rowcount=0)
36+
mock_session.execute.return_value = mock_result
37+
mock_session.commit.side_effect = [
38+
IntegrityError("stmt", "params", Exception("duplicate key")),
39+
]
40+
41+
await svc.touch_experiment(mock_session, "user1", "team1", "exp1")
42+
43+
mock_session.rollback.assert_called_once()
44+
45+
46+
async def test_get_recent_experiment_ids_returns_ordered_list():
47+
mock_session = AsyncMock()
48+
record1 = MagicMock()
49+
record1.experiment_id = "exp_b"
50+
record2 = MagicMock()
51+
record2.experiment_id = "exp_a"
52+
mock_result = MagicMock()
53+
mock_result.scalars.return_value.all.return_value = [record1, record2]
54+
mock_session.execute.return_value = mock_result
55+
56+
result = await svc.get_recent_experiment_ids(mock_session, "user1", "team1", limit=3)
57+
58+
assert result == ["exp_b", "exp_a"]

api/test/test_notes_helpers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,30 @@ async def _run():
148148
mock_storage.rm.assert_not_called()
149149

150150
asyncio.run(_run())
151+
152+
153+
def test_resolve_unique_asset_filename_returns_original_when_available():
154+
async def _run():
155+
from transformerlab.routers.experiment.notes import resolve_unique_asset_filename
156+
157+
with patch("transformerlab.routers.experiment.notes.storage") as mock_storage:
158+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
159+
mock_storage.exists = AsyncMock(return_value=False)
160+
result = await resolve_unique_asset_filename("/experiments/exp1/notes/assets", "image.png")
161+
assert result == "image.png"
162+
163+
asyncio.run(_run())
164+
165+
166+
def test_resolve_unique_asset_filename_adds_incrementing_suffix_on_collision():
167+
async def _run():
168+
from transformerlab.routers.experiment.notes import resolve_unique_asset_filename
169+
170+
# image.png exists, image-1.png exists, image-2.png is available.
171+
with patch("transformerlab.routers.experiment.notes.storage") as mock_storage:
172+
mock_storage.join.side_effect = lambda *parts: "/".join(parts)
173+
mock_storage.exists = AsyncMock(side_effect=[True, True, False])
174+
result = await resolve_unique_asset_filename("/experiments/exp1/notes/assets", "image.png")
175+
assert result == "image-2.png"
176+
177+
asyncio.run(_run())

0 commit comments

Comments
 (0)