Kuncen
Kuncen

One holder.
One queue.
No arguments.

One box. Four people. Throughput is fine for one user at a time and falls apart under concurrency. Kuncen serialises access to it: exactly one person holds an exclusive lock, everyone else waits in an ordered queue, and the handoff happens on a rule rather than an argument in chat.

LOCK · HELD by Alice · idle 04:12 / 05:00

The bet

A semaphore has
no defensible N

Two concurrent heavy requests is exactly the pathology we are eliminating — so N = 2 is not a compromise, it is the problem with extra steps. And a semaphore cannot tell a one-line question from a 30,000-token agentic run, so no value of N describes the load it is supposed to be limiting.

So Kuncen takes the other option: strict exclusive access. The price is that the GPU will sometimes sit near-idle under lock. That is a bet, and it is a measurable one — every session records who held it, for how long, how many requests they sent, and how much of that hold the box was actually busy. After two weeks the logs answer it.

“If the logs show frequent queueing with low utilization, revisit.” PLAN.md — The bet
The lock changing hands

Scroll it through
one full handoff

Seven ticks of the sweeper, which runs inside the proxy once a second. The panel is the state it is reading and writing.

01 Free

Nobody holds it

The queue is empty, the lock is FREE, and the sweeper has nothing to do. It still ticks — every second, transactionally, taking the current time as a parameter rather than reading a clock of its own.

02 Acquire

Alice presses Request access

The lock is free, so she gets it on the spot. The write is UPDATE lock_state … WHERE status = 'FREE' inside an immediate transaction, with the affected row count checked — two people clicking in the same second must not both win.

03 Queue

Bob presses it too

He is appended at position 1, and Alice's browser says Bob is requesting access — the whole social contract is that she hands it over, and she cannot do that if nobody tells her. Pressing it twice is a no-op; an impatient double-click must not send you to the back.

04 Timers arm

Now the clocks start mattering

The timers are contention-gated: they only run while somebody is waiting. And idle is measured retroactively — Alice went to lunch eight minutes ago, so she is already past the five-minute line the instant Bob arrives. An absent holder does not get a fresh five minutes for showing up to nothing.

05 Draining

DRAINING, not a guillotine

New requests get 423 immediately, but anything already streaming is allowed to finish — up to a 120-second ceiling. Without the ceiling a single max_tokens: 100000 generation could stretch a session arbitrarily past its cap, which defeats the cap.

06 Kill

Close the upstream, not just the client

At the ceiling the proxy aborts the connection to the model. Dropping only the client would leave the GPU generating a reply nobody will ever read, on time that now belongs to Bob — he would get exclusive access to a box secretly still working on Alice's prompt.

07 Promote

Bob has it, on the same tick

No accept step and no claim window: the sweeper promotes the queue head the moment the lock frees. If Bob is away, the ordinary idle timeout passes it along to whoever is next. One timer, one rule.

Dashboard sweeper 1s
FREE Available to use
Queue
empty
In flight
0 requests
Backend
up · /health answered 200
Idle timeoutnot running
Session capnot running
Drain ceiling
events Waiting for someone to ask.

Load-bearing rules

Break one and the
lock is decorative

Every one of these exists because the obvious alternative fails in a way that is quiet, plausible, and unfair to somebody.

Correctness

Absolute timestamps, never countdowns

Every deadline is an epoch millisecond, and the sweeper tick takes the time as an argument. A restart, a suspended VM or a nine-hour gap between ticks produces exactly the same state as a punctual one.

Fairness

Timers only run under contention

With nobody waiting, expiring a lock accomplishes nothing except sending the holder back to a web page to press a button and resume what they were already doing. The timers exist to stop one person starving the others — so they run when there is somebody to starve.

Streaming

In-flight suspends the idle clock

last_activity_at is stamped when the last token flushes, not when the request starts. A six-minute agentic generation must not drop the lock out from under itself. Only the session cap can ever catch you mid-stream.

Honesty

An open tab is not usage

Polling the dashboard does not reset anything, and there is no “extend me” button. With one, the queue would stop advancing at the only moment it matters.

Clients

423, never 429

openai-python silently auto-retries a 429, turning a clean “you're queued” into a hang and then a failure. A 423 fails fast and legibly, carrying Retry-After, the current holder, and your position in the body.

Privacy

Prompts are never logged

The proxy sees every prompt your colleagues write. The lock machinery records only structured metadata it built itself. Full request tracing exists for debugging, but it is off by default, announces itself in a banner the whole time it runs, and needs a restart rather than a checkbox.

Admin

Override leaves a name

A force-release requires a written reason and shows up on the dashboard. Not for audit — for friction. There is no queue reordering and no handing the lock to a named person, because that turns a mechanical system into a political one.

Operations

Health has three states

Up, down, and unknown. “We found nowhere to ask” is not “it is dead” — an OpenAI-compatible gateway happily 404s /health while serving inference, and a badge that cries wolf gets ignored. Backend health is shown separately from lock state, so “it's broken” and “it's taken” never look alike.


What your tools see

Paste the key once.
Never touch it again.

The API key answers who you are; the lock answers whether you may go right now. Keeping those separate is what lets Cline, a script or plain curl be configured a single time instead of re-pasting a token every session.

shell
export OPENAI_BASE_URL=http://spark.local:8080/v1
export OPENAI_API_KEY=kuncen_…

The proxy will not enqueue you. Joining the queue is a deliberate button press on the dashboard, always — so the waiting list is a list of people who actually want the box, not of retry loops.

Response 423 Locked
{
  "error": {
    "message": "Kuncen: the DGX Spark is held by
                 Alice. You are #2 of 3 in the queue.",
    "type": "kuncen_locked"
  },
  "kuncen": {
    "status": "HELD",
    "holder": "Alice",
    "expires_in_seconds": 240,
    "expiry_reason": "idle",
    "queue_position": 2,
    "queue_length": 3,
    "dashboard": "http://spark.local:3000"
  }
}

/v1/models and /health need a key but not the lock. Everything else under /v1/* needs both.


Under the hood

Two processes,
one SQLite file

The proxy holds streaming connections open for minutes; the dashboard churns for months. Under one process, every CSS tweak restarts the thing currently streaming somebody's generation. So the enforcing half is small, boring, and left alone.

The sweeper lives inside the proxy for the same kind of reason: if the sweeper is dead the proxy is dead, so nothing can reach the resource anyway. There is no state in which locks quietly stop expiring while people are still burning GPU time — the failure is total and obvious rather than partial and unfair.

CLIENTS · CLINE, SCRIPTS, CURL BROWSERS Bearer kuncen_… session cookie kuncen-proxy :8080 · /v1/* enforces the lock runs the sweeper, every 1s kuncen-web :3000 dashboard, queue, profile admin and force-release kuncen.db SQLite, WAL upstream 127.0.0.1:8000 · vLLM unreachable from the LAN — that is what makes the lock real
1sSweeper tick
112Tests
0Build steps
0Bundlers
1SQLite file
423Never 429
What it guards

The resource is
a parameter

Kuncen began as a lock on one DGX Spark, but nothing in the lock, the queue or the timers knows what sits behind the proxy. Name it once and it reaches the login page, the dashboard headlines, the browser notifications, and the 423 body a blocked tool prints.

The article is a separate setting because English does not survive a find-and-replace: the DGX Spark is free and Build Server 3 is free are both correct and differ by more than the noun.

.env
# a GPU box
KUNCEN_RESOURCE_NAME=DGX Spark
KUNCEN_RESOURCE_ARTICLE=the

# or anything else that speaks HTTP
KUNCEN_RESOURCE_NAME=Build Server 3
KUNCEN_RESOURCE_ARTICLE=

KUNCEN_UPSTREAM=http://127.0.0.1:8000
KUNCEN_DB=./data/kuncen.db
Run it

Four commands
to a working lock

Node 22, one SQLite file, no build step and no linter — both services run TypeScript straight through tsx. Accounts are provisioned by hand; each one prints a password and an API key exactly once.

first run
git clone https://github.com/awirayaksa/kuncen
npm install
npm run migrate
npm run admin -- user add alice@example.com Alice --admin

# then, in two terminals
npm run start:proxy   # :8080
npm run start:web     # :3000