Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog/unreleased/inflight-queue-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- 1. Overview InflightLiveCard selecting (选号) wait rail is visible again: expanded `/api/live` polls at 400ms, 1.6s display dwell (Acquire still immediate), and queue→slot fly-in uses GSAP MotionPath into the bound concurrency cell; cell model/elapsed stays hidden until the fly lands (or a short grace if skipped).
- 2. `/api/live` adds `occupancy` from BoundRequests: each in-flight request maps to its own concurrency cell (model + Bind elapsed). Finishing cells fly to Done/Failed from their own rect (`recent_completions.request_id`), staggered when several finish together — not one shared account animation.
- 3. Worker rail and wait rail use GSAP FLIP for yield/reorder: busy rises with a drag-up feel; remaining wait pills slide up into vacated gaps without fighting the next queue→slot fly.

### 中文

- 1. Overview「实时进行中」选号/排队重新可见:展开后 `/api/live` 400ms 轮询、选号展示约 1.6s(Acquire 不拖延);排队→槽位用 GSAP 弧线滑入 Bind 并发格,模型/计秒等飞入落地后再显示(飞不起来则短 grace)。
- 2. `/api/live` 增加 `occupancy`(BoundRequests):每个在途请求占一个并发格(各格模型与 Bind 秒数);结束时各格从自身飞向完成/失败(`recent_completions.request_id`),同帧多格会错开——不是整账号共用一个飞出动画。
- 3. Worker 轨与等待轨用 GSAP FLIP 避让上顶:busy 上浮、空闲下沉;选号 pill 飞走后剩余排队上滑补位,不和下一次飞入打架。
4 changes: 2 additions & 2 deletions changelog/unreleased/overview-pool-cooldown-chip.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
### English

- Overview Account pool collapses cooling accounts into a summary chip (`N cooling`); details expand on click with a quiet secondary wash. Ready workers stay primary in the list.
- 4. Overview Account pool collapses cooling accounts into a summary chip (`N cooling`); details expand on click with a quiet secondary wash. Ready workers stay primary in the list.

### 中文

- Overview「账号池」将冷却账号收成摘要 chip(「N 冷却」),点击后以安静次级底展开明细;列表优先展示就绪 worker。
- 4. Overview「账号池」将冷却账号收成摘要 chip(「N 冷却」),点击后以安静次级底展开明细;列表优先展示就绪 worker。
210 changes: 208 additions & 2 deletions frontend/scripts/mockInflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getMockInflightSnapshot,
liveHasSignal,
MOCK_INFLIGHT,
tickMockInflight,
} from '../src/lib/mockInflight.ts'

function run(name: string, fn: () => void) {
Expand Down Expand Up @@ -307,8 +308,6 @@ run('applyLiveProjection maps max_inflight / in_flight to slot cells', () => {
assert.equal(idle?.busy, false)
})

console.log('all mockInflight tests passed')

run('applyLiveProjection maps queue onto queued MockJobStatus', () => {
const started = new Date(Date.now() - 5_000).toISOString()
const snap = applyLiveProjection(getMockInflightSnapshot(), {
Expand Down Expand Up @@ -341,6 +340,35 @@ run('applyLiveProjection maps queue onto queued MockJobStatus', () => {
assert.equal(queued[1].id, 'req-2')
})

run('applyLiveProjection keeps queue accountId for slot handoff', () => {
const snap = applyLiveProjection(getMockInflightSnapshot(), {
in_flight: 1,
rpm: 1,
workers: [
{
id: 'acc-x',
name: 'wb-x',
provider: 'workbuddy',
busy: true,
progress: 0.5,
rate_limited: false,
},
],
rate_limits: [],
sticky: { bindings: 0, hits: 0, misses: 0, capacity: 64, ttl_seconds: 600 },
queue: [
{ id: 'req-dwell', name: 'gpt · dwell', status: 'started', account_id: 'acc-x' },
],
})
const queued = snap.jobs.filter((j) => j.status === 'queued')
assert.equal(queued.length, 1)
assert.equal(queued[0].id, 'req-dwell')
assert.equal(queued[0].accountId, 'acc-x')
const running = snap.jobs.filter((j) => j.status === 'running')
assert.equal(running.length, 1)
assert.equal(running[0].name, 'gpt · dwell')
})

run('liveHasSignal true when only queue present', () => {
assert.equal(
liveHasSignal({
Expand All @@ -354,3 +382,181 @@ run('liveHasSignal true when only queue present', () => {
true,
)
})

run('applyLiveProjection occupancy maps per-cell model and started_at', () => {
const t0 = new Date(Date.now() - 8_000).toISOString()
const t1 = new Date(Date.now() - 3_000).toISOString()
const snap = applyLiveProjection(getMockInflightSnapshot(), {
in_flight: 2,
rpm: 1,
workers: [
{
id: 'acc-m',
name: 'wb-m',
provider: 'workbuddy',
busy: true,
progress: 0.5,
rate_limited: false,
max_inflight: 4,
in_flight: 2,
},
],
rate_limits: [],
sticky: { bindings: 0, hits: 0, misses: 0, capacity: 64, ttl_seconds: 600 },
occupancy: [
{ id: 'req-a', name: 'claude-sonnet', status: 'started', started_at: t0, account_id: 'acc-m' },
{ id: 'req-b', name: 'gpt-4.1', status: 'started', started_at: t1, account_id: 'acc-m' },
],
})
const w = snap.workers.find((x) => x.id === 'acc-m')
assert.ok(w)
assert.equal(w.cells?.length, 2)
assert.equal(w.cells?.[0].id, 'req-a')
assert.equal(w.cells?.[0].name, 'claude-sonnet')
assert.equal(w.cells?.[1].id, 'req-b')
assert.equal(w.cells?.[1].name, 'gpt-4.1')
assert.ok((w.cells?.[0].elapsedMs ?? 0) >= 7_000)
assert.ok((w.cells?.[1].elapsedMs ?? 0) >= 2_000)
assert.ok((w.cells?.[0].elapsedMs ?? 0) > (w.cells?.[1].elapsedMs ?? 0))
const running = snap.jobs.filter((j) => j.status === 'running')
assert.equal(running.length, 2)
assert.equal(running[0].id, 'req-a')
assert.equal(running[1].name, 'gpt-4.1')
})

run('applyLiveProjection occupancy dwell stays on queue not double-running', () => {
const snap = applyLiveProjection(getMockInflightSnapshot(), {
in_flight: 1,
rpm: 1,
workers: [
{
id: 'acc-d',
name: 'wb-d',
provider: 'workbuddy',
busy: true,
progress: 0.4,
rate_limited: false,
max_inflight: 2,
in_flight: 1,
},
],
rate_limits: [],
sticky: { bindings: 0, hits: 0, misses: 0, capacity: 64, ttl_seconds: 600 },
queue: [{ id: 'req-dwell', name: 'sonnet', status: 'started', account_id: 'acc-d' }],
occupancy: [{ id: 'req-dwell', name: 'sonnet', status: 'started', account_id: 'acc-d' }],
})
const queued = snap.jobs.filter((j) => j.status === 'queued')
const running = snap.jobs.filter((j) => j.status === 'running')
assert.equal(queued.length, 1)
assert.equal(queued[0].id, 'req-dwell')
assert.equal(running.length, 0)
const w = snap.workers.find((x) => x.id === 'acc-d')
assert.equal(w?.cells?.length, 1)
assert.equal(w?.cells?.[0].id, 'req-dwell')
assert.equal(w?.cells?.[0].name, 'sonnet')
// Dwell-bound cell is a fly target but chrome stays gated until land.
assert.equal(w?.cells?.[0].pendingInbound, true)
})

run('applyLiveProjection occupancy without queue is not pendingInbound', () => {
const snap = applyLiveProjection(getMockInflightSnapshot(), {
in_flight: 1,
rpm: 1,
workers: [
{
id: 'acc-p',
name: 'wb-p',
provider: 'workbuddy',
busy: true,
progress: 0.5,
rate_limited: false,
max_inflight: 2,
in_flight: 1,
},
],
rate_limits: [],
sticky: { bindings: 0, hits: 0, misses: 0, capacity: 64, ttl_seconds: 600 },
occupancy: [{ id: 'req-run', name: 'opus', status: 'started', account_id: 'acc-p' }],
})
const w = snap.workers.find((x) => x.id === 'acc-p')
assert.equal(w?.cells?.[0].id, 'req-run')
assert.equal(w?.cells?.[0].pendingInbound, false)
assert.equal(snap.jobs.filter((j) => j.status === 'running').length, 1)
})

run('applyLiveProjection request_id completion matches that cell', () => {
const busy = applyLiveProjection(cloneMockInflight(MOCK_INFLIGHT), {
in_flight: 2,
rpm: 1,
workers: [
{
id: 'acc-x',
name: 'wb-x',
provider: 'workbuddy',
busy: true,
progress: 0.5,
rate_limited: false,
max_inflight: 4,
in_flight: 2,
},
],
rate_limits: [],
sticky: { bindings: 0, hits: 0, misses: 0, capacity: 64, ttl_seconds: 600 },
occupancy: [
{ id: 'req-keep', name: 'keep', status: 'started', account_id: 'acc-x' },
{ id: 'req-gone', name: 'gone', status: 'started', account_id: 'acc-x' },
],
})
assert.equal(busy.jobs.filter((j) => j.status === 'running').length, 2)
const after = applyLiveProjection(busy, {
in_flight: 1,
rpm: 1,
workers: [
{
id: 'acc-x',
name: 'wb-x',
provider: 'workbuddy',
busy: true,
progress: 0.5,
rate_limited: false,
max_inflight: 4,
in_flight: 1,
},
],
rate_limits: [],
sticky: { bindings: 0, hits: 0, misses: 0, capacity: 64, ttl_seconds: 600 },
occupancy: [{ id: 'req-keep', name: 'keep', status: 'started', account_id: 'acc-x' }],
recent_completions: [{ request_id: 'req-gone', account_id: 'acc-x', status: 'error' }],
})
assert.equal(after.jobs.filter((j) => j.status === 'running').length, 1)
assert.equal(after.jobs.find((j) => j.status === 'running')?.id, 'req-keep')
assert.ok(after.completions?.some((c) => c.id === 'req-gone' && c.ok === false))
assert.equal(after.workers.find((w) => w.id === 'acc-x')?.cells?.length, 1)
})

run('tickMockInflight finishes one cell without clearing siblings', () => {
const base = cloneMockInflight(MOCK_INFLIGHT)
const w = base.workers.find((x) => x.id === 'acc-wb-a1')
assert.ok(w && (w.cells?.length ?? 0) >= 2)
const siblingIds = (w!.cells ?? []).map((c) => c.id).filter((id) => id !== 'j1')
assert.ok(siblingIds.length >= 1)
// Force first cell to completion threshold; leave siblings mid-flight.
for (const j of base.jobs) {
if (j.id === 'j1') j.progress = 0.999
else if (j.accountId === 'acc-wb-a1') j.progress = 0.4
}
const next = tickMockInflight(base, 300)
const w2 = next.workers.find((x) => x.id === 'acc-wb-a1')
assert.ok(w2)
// Finished cell is gone (may refill from queue, so length can stay the same).
assert.ok(!(w2!.cells ?? []).some((c) => c.id === 'j1'))
// At least one prior sibling still occupies a cell — account not wiped.
assert.ok(siblingIds.some((id) => (w2!.cells ?? []).some((c) => c.id === id)))
assert.ok((w2!.inFlight ?? 0) >= 1)
// One completion (or retry) for j1 only — not a bulk clear of all cells.
const terminal = next.completions?.filter((c) => c.id === 'j1') ?? []
const retried = next.jobs.some((j) => j.id === 'j1' && j.status === 'queued')
assert.ok(terminal.length === 1 || retried)
})

console.log('all mockInflight tests passed')
16 changes: 11 additions & 5 deletions frontend/src/api/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,26 @@ export type LiveWorker = {

/** Terminal request finish drained this /api/live poll (Done vs Failed fly-out). */
export type LiveCompletion = {
/** Open request id — per-cell Done/Failed fly-out. */
request_id?: string
account_id: string
/** ok | error | incomplete | canceled */
status: string
}

/**
* Selecting (选号) request for the InflightLiveCard waiting rail.
* status reuses request_logs RequestStatus* (typically started) — not a new machine.
* Selecting (选号) or bound occupancy request for InflightLiveCard.
* status reuses request_logs RequestStatus* — not a new machine.
*/
export type LiveJob = {
id: string
name: string
/** RequestStatus* from recorder (started while selecting). */
/** RequestStatus* from recorder (started while selecting / bound). */
status: string
/** RFC3339 Prepare/Start time. */
/** RFC3339: queue = Prepare/Start; occupancy = Bind (slot entry). */
started_at?: string
/** Queue: dwell fly target. Occupancy: always set (cell owner). */
account_id?: string
}

/** Active cooldown window from AccountView.DownUntil / model_cooldowns. */
Expand Down Expand Up @@ -75,8 +79,10 @@ export type LiveSnapshot = {
sticky: LiveSticky
/** Terminal finishes since last poll — InflightLiveCard routes fly-outs. */
recent_completions?: LiveCompletion[]
/** Prepare→Acquire 选号 (RequestStatusStarted, no pool bind). */
/** Prepare→Acquire 选号 (RequestStatusStarted / dwell). */
queue?: LiveJob[]
/** Bound open requests — one per concurrency cell (model + per-cell started_at). */
occupancy?: LiveJob[]
}

export function fetchLive() {
Expand Down
Loading
Loading