Skip to content

Running a Task

Booting a server, reading the startup log, and handing it over. It ships with the potato-tasks skill:

/plugin marketplace add davidjurgens/potato-skill
/plugin install potato-tasks@potato

potato validate tells you the server will start. potato preview --screenshot tells you the first 900 pixels of one page did not throw. Neither tells you an annotator can get from the login screen to the end of the study, and most of what breaks a task lives in that gap.

This file covers the three things the rest of the pack does not: how to get a server up and leave it up, how to read what it says while booting, and how to drive it like an annotator.

Boot it early

The startup log is the real validator. It is the only place that reports:

  • whether each phase assembled, or was skipped
  • how many training, attention-check and gold items actually loaded
  • which subsystems initialized
  • exceptions from side files that validate never opens

Run it as soon as the config parses, well before the interface is finished. Every undocumented file format in this pack was recovered from these lines.

grep -E "ERROR|Traceback|Loaded [0-9]+|phase|missing required" server.log

Backgrounding it

potato start never returns. In a non-interactive session, run it detached and poll for readiness. Never in the foreground, and never behind a bare sleep.

cd /path/to/project                     # must be the directory task_dir resolves to
nohup potato start config.yaml -p 8421 > server.log 2>&1 &

until curl -s -o /dev/null http://localhost:8421/; do sleep 2; done
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8421/    # 200

Boot takes 10–20 seconds on a small task. The readiness lines in the log are Serving Flask app 'potato.flask_server' and Running on …. There is no /health endpoint on a plain potato start; a 200 on / is the check.

For a throwaway boot — you want the log, not a server — start it, wait for the readiness line or the process to die, then kill the process group:

import subprocess, os, signal, time
p = subprocess.Popen(["potato", "start", "config.yaml", "-p", "8951"], cwd=PROJ,
                     stdout=open("/tmp/boot.log", "wb"), stderr=subprocess.STDOUT,
                     start_new_session=True)
t0 = time.time()
while time.time() - t0 < 120:
    time.sleep(0.5)
    s = open("/tmp/boot.log", "rb").read().decode("utf8", "replace")
    if "Running on" in s or "Serving Flask" in s or p.poll() is not None:
        break
os.killpg(os.getpgid(p.pid), signal.SIGKILL); p.wait()

start_new_session=True plus killpg matters: the server spawns children and a plain kill leaves the port held.

Stop a named server with pkill -f "potato start config.yaml -p 8421" and confirm with lsof -nP -iTCP:8421 -sTCP:LISTEN.

Running the server writes to the project directory

Path What it is Safe to delete?
annotation_output/ Annotations, per-user state, exports Yes, while stopped. It is the data.
annotation_output/<user>/user_state.json One annotator's assignment, answers, spans, training state Never edit by hand
project.sqlite (+ -wal, -shm) Codebook, cases, memos, search index Yes, while stopped; it reseeds
layouts/task_layout_*.html Generated form HTML, cached by config hash Delete after editing schemes if the page looks stale
server.log Whatever you redirected Yes

Wiping annotation_output/ while the server runs does not reset anything: item and user state are already in memory, and the next annotator to arrive gets the completion page with zero items. If a fresh task suddenly says "Thank You!" to a new account, this is why. Stop the server, wipe, start it again.

Two consequences worth knowing before you verify a config you are still editing:

  • The first boot seeds project.sqlite from the config. For a codebook task the labels in your config become the project codebook, and later config edits do not change it until potato codebook config.yaml re-syncs.
  • Test annotators you create while checking the task are real annotators. They hold assignments and their answers count toward agreement. Wipe annotation_output/ and restart before handing the task over, and say in the handover which accounts you left behind.

Known console noise

Potato logs these on every phase page (consent, instructions, training, post-study), because a phase page has no instance and the span layer asks for one anyway:

GET /api/current_instance  404
[SpanManager] Error fetching current instance ID: Failed to fetch current instance: 404
[SpanManager] Failed to get server instance ID during initialization
ERROR potato.flask_server: Error getting instance text: 'null'

The 404 is the endpoint's own guard: it refuses to hand back an instance outside the annotation phase, which is right, and the span layer asks anyway. On the annotation page the same call returns 200.

Before Potato 2.8.2 a POST /api/track_annotation_change 400 joined this list, once per answer given on a phase page, and the behavioural trail for survey and training pages was lost. The three tracking endpoints now agree: a missing instance id becomes the __phase_page__ sentinel. That 400 in your log dates the checkout.

They appear on a task that is working correctly, and they have a consequence: potato preview --phase consent --screenshot exits 1 on a healthy task, while --screenshot on the annotation page exits 0 and says "rendered cleanly". Same config, both fine. Judge the PNG and the error list rather than $?. Filter them out before treating console errors as signal, and do not spend time chasing them.

Real errors worth reacting to, seen from the client:

Client symptom Meaning
[NAV] Navigation failed: 400 The server refused the save. Read the response body — it is JSON with unsatisfied_schemas
POST /annotate 400 {"status":"validation_error"} A required scheme is unanswered; a required span produces this with no inline message
A scheme renders with no inputs under the heading Generator failed for that scheme; the startup log names it
A scheme is missing entirely Same cause. Invalid label format: True in the log means a YAML boolean got into labels — see building-the-ui.md

Widgets that open a native dialog

Eight files in Potato call alert(), and error_span is the one you will meet first: saving an error span without a severity pops "Please select a severity level." Playwright dismisses dialogs automatically, so under automation this reads as a button that did nothing — no error, no console line, no change on the page. If a widget silently refuses to accept input, check for an alert() in its schema module before assuming the widget is broken.

Register a handler if you want to see them:

page.on("dialog", lambda d: (print("DIALOG:", d.message), d.dismiss()))

The admin surfaces

They are not linked from the annotation page, and the JSON APIs are not open.

cat admin_api_key.txt      # generated into task_dir at first boot; the log says so
curl -H "X-API-Key: $(cat admin_api_key.txt)" localhost:8000/admin/iaa
Route Auth What it gives
/admin none — serves HTML The dashboard
/admin/iaa X-API-Key Agreement per scheme: inferred kind, the metrics that follow, n_overlap_items
/admin/api/agreement X-API-Key Also agreement. Reported broken on a geometry task (calculate_krippendorffs_alpha() got an unexpected keyword argument 'experiment_col'); worked on a plain radio/likert task. Prefer /admin/iaa
/admin/api/quality_control X-API-Key Attention-check and gold-standard pass rates per user

Without the header every JSON route returns 403 {"error":"Admin access required"}, which reads like a broken build if you do not know the key exists. The key is 43 characters, regenerated only if the file is deleted, and settable yourself with admin_api_key in the config.

Driving it like an annotator

A static render cannot show you a conditional question, a drawn span, a restored answer, or whether the workflow reaches its own last page. If a browser tool is available, use it against a running server. Playwright ships with the [preview] extra that --screenshot needs, so it is normally already installed.

This is a working driver. It registers, walks the phases, screenshots each page full height, and reports console errors tagged by the phase they came from.

import sys
from playwright.sync_api import sync_playwright

BASE, SHOTS = "http://localhost:8421", "shots"
errors, phase = [], ["boot"]

def shot(page, tag):
    page.wait_for_timeout(800)
    page.screenshot(path=f"{SHOTS}/{tag}.png", full_page=True)   # full_page, not the viewport

def pick(page, name, value):                                     # click an input by name+value
    page.evaluate("""([n,v])=>{const e=document.querySelector(`input[name="${n}"][value="${v}"]`);
                    if(!e) throw new Error('missing '+n+'='+v); e.click();}""", [name, value])

with sync_playwright() as pw:
    b = pw.chromium.launch()
    page = b.new_context(viewport={"width": 1280, "height": 900}).new_page()
    page.on("console", lambda m: errors.append(f"[{phase[0]}] {m.text[:150]}") if m.type == "error" else None)
    page.on("pageerror", lambda e: errors.append(f"[{phase[0]}] {e}"))

    page.goto(BASE)
    page.evaluate("switchTab('register')")                       # login page has login/register tabs
    # NB: use a fresh username each run. A duplicate registration navigates to
    # /register, which has no switchTab(), so a re-run throws
    # "Cannot read properties of null (reading 'classList')" and leaves you
    # logged out on the sign-in page rather than on the task.
    for i in page.query_selector_all("form[action='/register'] input"):
        n = i.get_attribute("name")
        if n == "email": i.fill("checker@example.com")
        elif n == "pass": i.fill("pw12345")
    page.click("form[action='/register'] button[type=submit]")
    page.wait_for_load_state("networkidle")

    phase[0] = "consent"; shot(page, "10-consent")
    pick(page, "consent_agree", "I agree to take part")
    page.click("button:has-text('Next')"); page.wait_for_load_state("networkidle")
    ...

Selectors that are stable and worth knowing:

Selector What it is
#instance_id Hidden input holding the current item id. The cheapest proof you moved
#next-btn Next. There is no #prev-btn on the first item; Previous appears from item 2
#go_to + #go-to-btn Jump to instance number N. Use this to navigate back
input[name='<scheme>'][value='<label>'] Radio / likert option. value is the raw label, not the humanized one
input[name='span_label:::<scheme>'][value='<label>'] Span label chip; it is a checkbox.check(force=True)
[name='<scheme>:::text_box'] Free-text scheme. Not textarea[...] — a text scheme is an <input> unless multiline: true
#span-overlays Present only on an ordinary text task. Its children are the drawn spans, and its presence is how you tell the two shapes apart
#text-content The span anchor on an ordinary text task — but it exists on a span-target task too, hidden. Check the overlay first
.span-target-field .text-content The per-field anchor once instance_display names span-target fields. Overlays sit inside it, as .span-overlays-field > *
form.annotation-form One per scheme; counting these counts the questions on the page

Drawing a span

Spans are drawn as overlays over the text rather than by wrapping it, so "did a <mark> appear" is the wrong check. Count the overlay container's children, in whichever of the two shapes the task renders.

An ordinary text task renders one div#text-content holding the item text, with div#span-overlays inside it. A task whose instance_display names span-target fields renders one div#text-content-<field>.text-content per field, each with a div#span-overlays-<field>.span-overlays-field inside it.

Do not tell the two apart by asking whether #text-content exists. It exists on both. On a span-target task it is a 0×0 hidden div holding text_key, carried along beside the real per-field anchors, and Playwright refuses to scroll to it — scroll_into_view_if_needed times out with "element is not visible" after thirty seconds, which reads as a hung browser rather than a wrong selector. Anchor on the overlay container instead, which exists in one shape each:

anchor = ".text-content" if page.locator(".span-overlays-field").count() else "#text-content"
page.locator("input[name='span_label:::sentence_type'][value='Factual reporting']").first.check(force=True)
page.locator(anchor).first.scroll_into_view_if_needed()
page.mouse.wheel(0, -160)          # clear the sticky navbar, or the drag starts on it
box = page.evaluate("""(sel)=>{
    const c=document.querySelector(sel);
    const w=document.createTreeWalker(c, NodeFilter.SHOW_TEXT);
    let n=w.nextNode();
    while (n && n.textContent.trim().length < 25) n=w.nextNode();   // skip turn numbers and speaker chips
    const t=n.textContent, stop=t.indexOf('.');
    const r=document.createRange(); r.setStart(n,0); r.setEnd(n, stop>0?stop+1:40);
    const q=[...r.getClientRects()], a=q[0], z=q[q.length-1];
    return {x1:a.left+2, y1:a.top+a.height/2, x2:z.right-2, y2:z.top+z.height/2};}""", anchor)
page.mouse.move(box["x1"], box["y1"]); page.mouse.down()
for k in range(1, 11):
    page.mouse.move(box["x1"] + (box["x2"]-box["x1"])*k/10, box["y1"], steps=2)
page.mouse.up()

The mouse.wheel line is not optional. scroll_into_view_if_needed puts the text at y≈22, underneath the sticky navbar, and the drag then selects the header instead of the article. That looks like "spans are broken" and is not.

The length filter in the tree walker is there for the same reason. A dialogue span target starts with the turn number and the speaker name as their own text nodes, so the first node the walker reaches is [1] — five pixels wide, and a drag across it selects nothing.

This generalizes to every synthetic drag, spans and canvases alike. page.mouse works in viewport coordinates, so a target below the fold or under the navbar receives nothing:

  1. scrollIntoView({block: 'center'}) on the target element,
  2. re-read getBoundingClientRect() after the scroll,
  3. drag,
  4. count the result#span-overlays > * for spans (`.span-overlays-field

    *` per field on a span-target task), the canvas's own "Annotations: N" readout for geometry.

A drag that misses produces zero annotations, zero exceptions, zero console output and zero network traffic. It is indistinguishable from the feature being broken, and step 4 is the only thing that tells them apart.

The four checks worth automating

  1. Conditional schemes appear. Read the ancestor chain for display:none, not just the element:
page.evaluate("""s=>{let p=document.querySelector(s), hidden=false;
   while(p){ const cs=getComputedStyle(p);
             if(cs.display==='none'||cs.visibility==='hidden'){hidden=true;break;} p=p.parentElement; }
   return hidden;}""", "textarea[name='misleading_why:::text_box']")

A conditional input has a non-zero bounding box while hidden, so width and height prove nothing.

  1. Answers survive navigation. Answer, #next-btn, then #go_to + Go back. Check the visual state — input[name=x]:checked, textarea .value, overlay count — not hidden inputs. Never test this with driver.refresh(): browsers restore form state across a refresh on their own, so a refresh-based check passes even when the server stored nothing.

  2. The workflow reaches its last page. Answer every item and confirm the post-study page renders. This is where per-annotator quota bugs surface.

  3. The server really stored it. Read annotation_output/<user>/user_state.json:

d = json.load(open(f"annotation_output/{user}/user_state.json"))
d["instance_id_ordering"]              # what they were assigned, in order
d["instance_id_to_label_to_value"]     # answers per item
d["instance_id_to_span_to_value"]      # spans with start/end offsets
d["training_state"]["passed"]
d["phase_to_page_to_label_to_value"]   # consent and survey answers
d["max_assignments"]                   # the quota that was actually applied

This is the difference between "the widget is on the page" and "the answer is in the file", and it is the claim a researcher cares about.

What to say in the handover

Distinguish what you established from what you assumed. A useful report reads:

Rendered at 1280 wide, full height: consent, instructions, a training question with feedback, the annotation page, and the post-study survey (shots/). Walked one annotator end to end: training marked 3/3, all six items plus two attention checks annotated, answers and span offsets present in user_state.json. Conditional follow-ups verified by driving the gate answer; persistence verified by navigating away and back, not by refresh.

Not checked: two annotators at once, the agreement pages with real overlap, and whether an attention-check failure surfaces anywhere an admin would see it.

The second paragraph is what makes the first one worth reading.

Leave the server running only if that was asked for, and if you do, write down the URL, the port, the pid, how to stop it and how to start it again. A running server nobody can restart is a one-shot demo.