Add workplan execution queue

This commit is contained in:
2026-05-23 19:11:30 +02:00
parent 0ea46f081c
commit d4dea7864d
13 changed files with 1022 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ from api.routers import interface_changes
from api.routers import flows
from api.routers import recently_on_scope
from api.routers import reconciliation
from api.routers import execution
class ETagMiddleware(BaseHTTPMiddleware):
@@ -102,6 +103,7 @@ app.include_router(token_events.router)
app.include_router(interface_changes.router)
app.include_router(flows.router)
app.include_router(reconciliation.router)
app.include_router(execution.router)
app.include_router(state.router)
app.include_router(policy.router)

View File

@@ -21,6 +21,7 @@ from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
from api.models.doi_cache import DOICache
from api.models.token_event import TokenEvent
from api.models.interface_change import InterfaceChange
from api.models.workplan_launch_request import WorkplanLaunchRequest
__all__ = [
"Base",
@@ -46,4 +47,5 @@ __all__ = [
"DOICache",
"TokenEvent",
"InterfaceChange",
"WorkplanLaunchRequest",
]

View File

@@ -0,0 +1,39 @@
import uuid
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class WorkplanLaunchRequest(Base, TimestampMixin):
__tablename__ = "workplan_launch_requests"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
workstream_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
requested_by: Mapped[str] = mapped_column(String(100), nullable=False, default="dashboard")
requested_actor: Mapped[str | None] = mapped_column(String(100), nullable=True)
launch_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
concurrency_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="sequential", index=True)
priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
branch_preference: Mapped[str | None] = mapped_column(Text, nullable=True)
immediate_pickup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="requested", server_default="requested", index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="launch_requests") # noqa: F821

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import date
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, String, Text
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -27,6 +27,18 @@ class Workstream(Base, TimestampMixin):
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
launch_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
concurrency_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="sequential", server_default="sequential", index=True
)
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
@@ -53,3 +65,6 @@ class Workstream(Base, TimestampMixin):
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="workstream", lazy="selectin"
)
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
"WorkplanLaunchRequest", back_populates="workstream", lazy="selectin"
)

196
api/routers/execution.py Normal file
View File

@@ -0,0 +1,196 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task, TaskStatus
from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.schemas.execution import (
ExecutionIntentRead,
ExecutionIntentUpdate,
ExecutionSemantics,
LaunchRequestCreate,
LaunchRequestRead,
WorkplanQueueItem,
)
from api.services.execution_queue import (
ACTIVITY_CORE_RESPONSIBILITIES,
CONCURRENCY_MODES,
EXECUTION_STATES,
LAUNCH_MODES,
STATE_HUB_RESPONSIBILITIES,
execution_state_for_launch,
queue_sort_key,
workstream_blockers,
)
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
router = APIRouter(prefix="/execution", tags=["execution"])
@router.get("/semantics", response_model=ExecutionSemantics)
async def execution_semantics() -> ExecutionSemantics:
return ExecutionSemantics(
execution_states=EXECUTION_STATES,
launch_modes=LAUNCH_MODES,
concurrency_modes=CONCURRENCY_MODES,
state_hub_responsibility=STATE_HUB_RESPONSIBILITIES,
activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES,
)
@router.patch("/workstreams/{workstream_id}/intent", response_model=ExecutionIntentRead)
async def update_execution_intent(
workstream_id: uuid.UUID,
body: ExecutionIntentUpdate,
session: AsyncSession = Depends(get_session),
) -> ExecutionIntentRead:
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(ws, field, value)
await session.commit()
await session.refresh(ws)
return _intent_read(ws)
@router.get("/workplan-stack", response_model=list[WorkplanQueueItem])
async def workplan_stack(
include_manual: bool = Query(True),
include_blocked: bool = Query(True),
session: AsyncSession = Depends(get_session),
) -> list[WorkplanQueueItem]:
result = await session.execute(select(Workstream))
workstreams = [
ws for ws in result.scalars().all()
if normalize_workstream_status(ws.status) not in CLOSED_WORKSTREAM_STATUSES
]
ws_by_id = {ws.id: ws for ws in workstreams}
ws_status = {ws.id: normalize_workstream_status(ws.status) for ws in workstreams}
dep_result = await session.execute(select(WorkstreamDependency))
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
for dep in dep_result.scalars().all():
if dep.to_workstream_id is not None:
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id)
if dep.to_task_id is not None:
task_deps.setdefault(dep.from_workstream_id, []).append(dep.to_task_id)
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
task_status: dict[uuid.UUID, str] = {}
if task_ids:
task_result = await session.execute(select(Task).where(Task.id.in_(task_ids)))
task_status = {task.id: _task_status(task.status) for task in task_result.scalars().all()}
items: list[WorkplanQueueItem] = []
for ws in workstreams:
if not include_manual and ws.execution_state == "manual":
continue
lifecycle_status = normalize_workstream_status(ws.status)
blocked_ws = [
blocker for blocker in workstream_blockers(ws.id, ws_deps, ws_status)
if blocker in ws_by_id or blocker in ws_status
]
blocked_tasks = [
task_id for task_id in task_deps.get(ws.id, [])
if task_status.get(task_id) not in {"done", "cancelled"}
]
eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
if not include_blocked and not eligible:
continue
sort_key = queue_sort_key(ws, eligible=eligible)
items.append(WorkplanQueueItem(
workstream_id=ws.id,
slug=ws.slug,
title=ws.title,
status=lifecycle_status,
repo_id=ws.repo_id,
planning_priority=ws.planning_priority,
planning_order=ws.planning_order,
execution_state=ws.execution_state,
launch_mode=ws.launch_mode,
concurrency_mode=ws.concurrency_mode,
queue_rank=ws.queue_rank,
execution_group=ws.execution_group,
scheduled_for=ws.scheduled_for,
eligible=eligible,
blocked_by_workstream_ids=blocked_ws,
blocked_by_task_ids=blocked_tasks,
sort_key=sort_key,
))
return sorted(items, key=lambda item: item.sort_key)
@router.post(
"/launch-requests",
response_model=LaunchRequestRead,
status_code=status.HTTP_201_CREATED,
)
async def create_launch_request(
body: LaunchRequestCreate,
session: AsyncSession = Depends(get_session),
) -> WorkplanLaunchRequest:
ws = await session.get(Workstream, body.workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
launch_request = WorkplanLaunchRequest(
workstream_id=ws.id,
requested_by=body.requested_by,
requested_actor=body.requested_actor,
launch_mode=body.launch_mode,
concurrency_mode=body.concurrency_mode,
priority=body.priority or ws.planning_priority,
repo_id=body.repo_id or ws.repo_id,
branch_preference=body.branch_preference,
immediate_pickup=body.immediate_pickup,
notes=body.notes,
request_metadata=body.request_metadata,
)
ws.launch_mode = body.launch_mode
ws.concurrency_mode = body.concurrency_mode
ws.execution_state = execution_state_for_launch(body.launch_mode, body.immediate_pickup)
session.add(launch_request)
await session.commit()
await session.refresh(launch_request)
return launch_request
@router.get("/launch-requests", response_model=list[LaunchRequestRead])
async def list_launch_requests(
workstream_id: uuid.UUID | None = None,
request_status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[WorkplanLaunchRequest]:
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
if workstream_id:
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id)
if request_status:
q = q.where(WorkplanLaunchRequest.status == request_status)
result = await session.execute(q)
return list(result.scalars().all())
def _intent_read(ws: Workstream) -> ExecutionIntentRead:
return ExecutionIntentRead(
workstream_id=ws.id,
execution_state=ws.execution_state,
launch_mode=ws.launch_mode,
concurrency_mode=ws.concurrency_mode,
queue_rank=ws.queue_rank,
execution_group=ws.execution_group,
scheduled_for=ws.scheduled_for,
)
def _task_status(status_value: TaskStatus | str) -> str:
if hasattr(status_value, "value"):
return status_value.value
return str(status_value or "").strip().lower()

91
api/schemas/execution.py Normal file
View File

@@ -0,0 +1,91 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
ConcurrencyMode = Literal["sequential", "parallel"]
LaunchRequestStatus = Literal["requested", "accepted", "completed", "cancelled"]
class ExecutionIntentUpdate(BaseModel):
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
class ExecutionIntentRead(BaseModel):
workstream_id: uuid.UUID
execution_state: ExecutionState
launch_mode: LaunchMode
concurrency_mode: ConcurrencyMode
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
class WorkplanQueueItem(BaseModel):
workstream_id: uuid.UUID
slug: str
title: str
status: str
repo_id: uuid.UUID | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState
launch_mode: LaunchMode
concurrency_mode: ConcurrencyMode
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
eligible: bool
blocked_by_workstream_ids: list[uuid.UUID] = Field(default_factory=list)
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
sort_key: list[str | int] = Field(default_factory=list)
class LaunchRequestCreate(BaseModel):
workstream_id: uuid.UUID
requested_by: str = "dashboard"
requested_actor: str | None = None
launch_mode: LaunchMode = "queued"
concurrency_mode: ConcurrencyMode = "sequential"
priority: str | None = None
repo_id: uuid.UUID | None = None
branch_preference: str | None = None
immediate_pickup: bool = False
notes: str | None = None
request_metadata: dict = Field(default_factory=dict)
class LaunchRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
workstream_id: uuid.UUID
requested_by: str
requested_actor: str | None = None
launch_mode: LaunchMode
concurrency_mode: ConcurrencyMode
priority: str | None = None
repo_id: uuid.UUID | None = None
branch_preference: str | None = None
immediate_pickup: bool
status: LaunchRequestStatus
notes: str | None = None
request_metadata: dict = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class ExecutionSemantics(BaseModel):
execution_states: dict[str, str]
launch_modes: dict[str, str]
concurrency_modes: dict[str, str]
state_hub_responsibility: list[str]
activity_core_responsibility: list[str]

View File

@@ -16,6 +16,9 @@ WorkstreamStatus = Literal[
"finished",
"archived",
]
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
ConcurrencyMode = Literal["sequential", "parallel"]
class WorkstreamStatusMixin(BaseModel):
@@ -35,6 +38,12 @@ class WorkstreamCreate(WorkstreamStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
repo_goal_id: uuid.UUID | None = None
@@ -47,6 +56,12 @@ class WorkstreamUpdate(WorkstreamStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
@@ -65,6 +80,12 @@ class WorkstreamRead(WorkstreamStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
from typing import Any
from api.workplan_status import normalize_workstream_status
EXECUTION_STATES = {
"manual": "Not queued for autonomous pickup; humans or agents may still work manually.",
"queued": "Candidate for ordered pickup when dependencies and concurrency allow it.",
"scheduled": "Waiting for an external launch window; State Hub stores the requested time.",
"launching": "A launch request asks for immediate pickup or has been handed off.",
"paused": "Temporarily held outside the pickup stack.",
"completed": "Execution intent is closed; lifecycle status remains authoritative.",
"cancelled": "Execution intent was cancelled without changing lifecycle status.",
}
LAUNCH_MODES = {
"manual": "Do not request automation; keep intent visible only.",
"queued": "Place in the prioritized stack for later pickup.",
"scheduled": "Request pickup at or after a selected time.",
"immediate": "Request prompt activity-core or agent pickup.",
}
CONCURRENCY_MODES = {
"sequential": "Respect queue order and avoid parallel pickup for the same group.",
"parallel": "Eligible for concurrent pickup with other ready work.",
}
STATE_HUB_RESPONSIBILITIES = [
"store lifecycle status separately from execution intent",
"rank candidate workplans and expose dependency-aware eligibility",
"record launch requests and handoff metadata durably",
"surface manual, queued, scheduled, and immediate intent to operators",
]
ACTIVITY_CORE_RESPONSIBILITIES = [
"own schedules, wakeups, and recurring automation",
"dispatch coding agents and coordinate parallel execution",
"acknowledge, run, and complete launch requests when available",
]
EXECUTION_STATE_RANK = {
"launching": 0,
"queued": 1,
"scheduled": 2,
"manual": 3,
"paused": 4,
"completed": 5,
"cancelled": 6,
}
PRIORITY_RANK = {
"critical": 0,
"high": 1,
"medium": 2,
"low": 3,
}
CLOSED_WORKSTREAM_STATUSES = {"finished", "archived"}
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
mode = (launch_mode or "queued").strip().lower()
if immediate_pickup or mode == "immediate":
return "launching"
if mode == "scheduled":
return "scheduled"
if mode == "manual":
return "manual"
return "queued"
def workstream_blockers(
workstream_id: Any,
dependency_targets: dict[Any, list[Any]],
workstream_status: dict[Any, str],
) -> list[Any]:
blockers = []
for target_id in dependency_targets.get(workstream_id, []):
target_status = normalize_workstream_status(workstream_status.get(target_id))
if target_status not in CLOSED_WORKSTREAM_STATUSES:
blockers.append(target_id)
return blockers
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
return [
0 if eligible else 1,
EXECUTION_STATE_RANK.get(execution_state, 99),
PRIORITY_RANK.get(priority, 50),
getattr(workstream, "queue_rank", None) if getattr(workstream, "queue_rank", None) is not None else 999_999,
getattr(workstream, "planning_order", None) if getattr(workstream, "planning_order", None) is not None else 999_999,
str(getattr(workstream, "slug", "") or ""),
]

View File

@@ -0,0 +1,291 @@
---
title: Workplan Queue
---
```js
import {apiFetch, waitForVisible, pollDelay, POLL_HEAVY} from "./components/config.js";
```
```js
const queueState = (async function*() {
let failures = 0;
while (true) {
let stack = [], semantics = {}, ok = false;
try {
const [stackResponse, semanticsResponse] = await Promise.all([
apiFetch("/execution/workplan-stack"),
apiFetch("/execution/semantics"),
]);
ok = stackResponse.ok && semanticsResponse.ok;
if (ok) {
[stack, semantics] = await Promise.all([
stackResponse.json(),
semanticsResponse.json(),
]);
}
} catch {}
failures = ok ? 0 : failures + 1;
yield {stack, semantics, ok, ts: new Date()};
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```
```js
const stack = queueState.stack ?? [];
const semantics = queueState.semantics ?? {};
const _ok = queueState.ok ?? false;
const _ts = queueState.ts;
```
# Workplan Queue
```js
display(html`<div class="queue-live">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok ? `Live · updated ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">Offline</span>`}
</div>`);
```
```js
const launchModes = Object.keys(semantics.launch_modes ?? {manual: "", queued: "", scheduled: "", immediate: ""});
const concurrencyModes = Object.keys(semantics.concurrency_modes ?? {sequential: "", parallel: ""});
function optionList(values, selected) {
return values.map(value => html`<option value=${value} selected=${value === selected}>${value}</option>`);
}
function statusCell(row) {
const classes = ["queue-status", row.eligible ? "eligible" : "blocked"].join(" ");
return html`<span class=${classes}>${row.eligible ? "eligible" : "blocked"}</span>`;
}
function blockers(row) {
const parts = [];
if (row.blocked_by_workstream_ids?.length) parts.push(`${row.blocked_by_workstream_ids.length} workstream`);
if (row.blocked_by_task_ids?.length) parts.push(`${row.blocked_by_task_ids.length} task`);
return parts.length ? parts.join(", ") : "—";
}
function queueControls(row) {
const root = html`<div class="queue-controls"></div>`;
const mode = html`<select class="queue-select">${optionList(launchModes, row.launch_mode)}</select>`;
const concurrency = html`<select class="queue-select">${optionList(concurrencyModes, row.concurrency_mode)}</select>`;
const rank = html`<input class="queue-rank" type="number" min="0" step="1" value=${row.queue_rank ?? ""} aria-label="Queue rank">`;
const group = html`<input class="queue-group" type="text" value=${row.execution_group ?? ""} aria-label="Execution group">`;
const message = html`<span class="queue-message"></span>`;
const save = html`<button class="queue-btn" type="button">Save</button>`;
const launch = html`<button class="queue-btn queue-btn-primary" type="button">Request</button>`;
const payload = () => ({
execution_state: mode.value === "manual" ? "manual" : mode.value === "scheduled" ? "scheduled" : "queued",
launch_mode: mode.value,
concurrency_mode: concurrency.value,
queue_rank: rank.value === "" ? null : Number(rank.value),
execution_group: group.value.trim() || null,
});
async function run(label, action) {
message.textContent = label;
message.className = "queue-message";
save.disabled = true;
launch.disabled = true;
try {
await action();
message.textContent = "saved";
message.classList.add("ok");
setTimeout(() => location.reload(), 450);
} catch (error) {
message.textContent = error?.message ?? "failed";
message.classList.add("error");
save.disabled = false;
launch.disabled = false;
}
}
save.onclick = () => run("saving", async () => {
const response = await apiFetch(`/execution/workstreams/${row.workstream_id}/intent`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload()),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
});
launch.onclick = () => run("requesting", async () => {
const intent = payload();
const response = await apiFetch("/execution/launch-requests", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
workstream_id: row.workstream_id,
requested_by: "dashboard",
requested_actor: "activity-core",
launch_mode: intent.launch_mode,
concurrency_mode: intent.concurrency_mode,
immediate_pickup: intent.launch_mode === "immediate",
priority: row.planning_priority,
notes: `Queue request from dashboard for ${row.slug}`,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
});
root.append(mode, concurrency, rank, group, save, launch, message);
return root;
}
```
```js
if (stack.length === 0) {
display(html`<p class="queue-empty">No queue candidates.</p>`);
} else {
display(html`<table class="queue-table">
<thead>
<tr>
<th>State</th>
<th>Rank</th>
<th>Workplan</th>
<th>Lifecycle</th>
<th>Priority</th>
<th>Eligibility</th>
<th>Blocked By</th>
<th>Intent</th>
</tr>
</thead>
<tbody>${stack.map(row => html`<tr>
<td>${row.execution_state}</td>
<td>${row.queue_rank ?? row.planning_order ?? "—"}</td>
<td><a href=${`./workstreams/${row.workstream_id}`}>${row.slug}</a><div class="queue-title">${row.title}</div></td>
<td>${row.status}</td>
<td>${row.planning_priority ?? "—"}</td>
<td>${statusCell(row)}</td>
<td>${blockers(row)}</td>
<td>${queueControls(row)}</td>
</tr>`)}</tbody>
</table>`);
}
```
<style>
.queue-live {
font-size: 0.82rem;
color: var(--theme-foreground-muted, #666);
margin: -0.25rem 0 0.75rem;
}
.queue-table {
width: 100%;
border-collapse: collapse;
font-size: 0.86rem;
}
.queue-table th,
.queue-table td {
border-bottom: 1px solid var(--theme-foreground-faint, #e5e7eb);
padding: 0.45rem 0.5rem;
vertical-align: middle;
text-align: left;
}
.queue-table th {
font-size: 0.72rem;
text-transform: uppercase;
color: var(--theme-foreground-muted, #666);
}
.queue-title {
color: var(--theme-foreground-muted, #666);
font-size: 0.76rem;
max-width: 28rem;
overflow-wrap: anywhere;
}
.queue-status {
display: inline-block;
min-width: 4.6rem;
text-align: center;
border-radius: 6px;
padding: 0.12rem 0.45rem;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
font-size: 0.76rem;
}
.queue-status.eligible {
color: #166534;
border-color: #bbf7d0;
background: #f0fdf4;
}
.queue-status.blocked {
color: #92400e;
border-color: #fde68a;
background: #fffbeb;
}
.queue-controls {
display: grid;
grid-template-columns: 7rem 7rem 4.2rem 7rem auto auto minmax(4rem, auto);
gap: 0.3rem;
align-items: center;
}
.queue-select,
.queue-rank,
.queue-group {
height: 1.85rem;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
border-radius: 6px;
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
font: inherit;
font-size: 0.78rem;
padding: 0.1rem 0.35rem;
min-width: 0;
}
.queue-btn {
height: 1.85rem;
border-radius: 6px;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
font: inherit;
font-size: 0.78rem;
padding: 0 0.55rem;
cursor: pointer;
}
.queue-btn-primary {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.queue-message {
font-size: 0.72rem;
color: var(--theme-foreground-muted, #666);
white-space: nowrap;
}
.queue-message.ok {
color: #16a34a;
}
.queue-message.error {
color: #dc2626;
}
.queue-empty {
color: var(--theme-foreground-muted, #666);
}
@media (max-width: 980px) {
.queue-table,
.queue-table thead,
.queue-table tbody,
.queue-table tr,
.queue-table th,
.queue-table td {
display: block;
}
.queue-table thead {
display: none;
}
.queue-table tr {
border-bottom: 1px solid var(--theme-foreground-faint, #e5e7eb);
padding: 0.5rem 0;
}
.queue-table td {
border: 0;
padding: 0.25rem 0;
}
.queue-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -215,6 +215,7 @@ injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/workstreams"); }
display(html`<p class="dim" style="margin-top:-0.25rem"><a href="./workplan-queue">Workplan queue</a></p>`);
```
```js

View File

@@ -0,0 +1,89 @@
"""add workplan execution queue and launch requests
Revision ID: x1s2t3u4v5w6
Revises: w0r1s2t3u4v5
Create Date: 2026-05-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "x1s2t3u4v5w6"
down_revision = "w0r1s2t3u4v5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"workstreams",
sa.Column("execution_state", sa.String(length=20), nullable=False, server_default="manual"),
)
op.add_column(
"workstreams",
sa.Column("launch_mode", sa.String(length=20), nullable=False, server_default="manual"),
)
op.add_column(
"workstreams",
sa.Column("concurrency_mode", sa.String(length=20), nullable=False, server_default="sequential"),
)
op.add_column("workstreams", sa.Column("queue_rank", sa.Integer(), nullable=True))
op.add_column("workstreams", sa.Column("execution_group", sa.String(length=100), nullable=True))
op.add_column("workstreams", sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_workstreams_execution_state", "workstreams", ["execution_state"])
op.create_index("ix_workstreams_launch_mode", "workstreams", ["launch_mode"])
op.create_index("ix_workstreams_concurrency_mode", "workstreams", ["concurrency_mode"])
op.create_index("ix_workstreams_queue_rank", "workstreams", ["queue_rank"])
op.create_index("ix_workstreams_execution_group", "workstreams", ["execution_group"])
op.create_index("ix_workstreams_scheduled_for", "workstreams", ["scheduled_for"])
op.create_table(
"workplan_launch_requests",
sa.Column("id", UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("workstream_id", UUID(as_uuid=True), nullable=False),
sa.Column("requested_by", sa.String(length=100), nullable=False),
sa.Column("requested_actor", sa.String(length=100), nullable=True),
sa.Column("launch_mode", sa.String(length=20), nullable=False),
sa.Column("concurrency_mode", sa.String(length=20), nullable=False),
sa.Column("priority", sa.String(length=20), nullable=True),
sa.Column("repo_id", UUID(as_uuid=True), nullable=True),
sa.Column("branch_preference", sa.Text(), nullable=True),
sa.Column("immediate_pickup", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="requested"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("request_metadata", JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["repo_id"], ["managed_repos.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["workstream_id"], ["workstreams.id"], ondelete="CASCADE"),
)
op.create_index("ix_workplan_launch_requests_workstream_id", "workplan_launch_requests", ["workstream_id"])
op.create_index("ix_workplan_launch_requests_launch_mode", "workplan_launch_requests", ["launch_mode"])
op.create_index("ix_workplan_launch_requests_concurrency_mode", "workplan_launch_requests", ["concurrency_mode"])
op.create_index("ix_workplan_launch_requests_priority", "workplan_launch_requests", ["priority"])
op.create_index("ix_workplan_launch_requests_repo_id", "workplan_launch_requests", ["repo_id"])
op.create_index("ix_workplan_launch_requests_status", "workplan_launch_requests", ["status"])
def downgrade() -> None:
op.drop_index("ix_workplan_launch_requests_status", table_name="workplan_launch_requests")
op.drop_index("ix_workplan_launch_requests_repo_id", table_name="workplan_launch_requests")
op.drop_index("ix_workplan_launch_requests_priority", table_name="workplan_launch_requests")
op.drop_index("ix_workplan_launch_requests_concurrency_mode", table_name="workplan_launch_requests")
op.drop_index("ix_workplan_launch_requests_launch_mode", table_name="workplan_launch_requests")
op.drop_index("ix_workplan_launch_requests_workstream_id", table_name="workplan_launch_requests")
op.drop_table("workplan_launch_requests")
op.drop_index("ix_workstreams_scheduled_for", table_name="workstreams")
op.drop_index("ix_workstreams_execution_group", table_name="workstreams")
op.drop_index("ix_workstreams_queue_rank", table_name="workstreams")
op.drop_index("ix_workstreams_concurrency_mode", table_name="workstreams")
op.drop_index("ix_workstreams_launch_mode", table_name="workstreams")
op.drop_index("ix_workstreams_execution_state", table_name="workstreams")
op.drop_column("workstreams", "scheduled_for")
op.drop_column("workstreams", "execution_group")
op.drop_column("workstreams", "queue_rank")
op.drop_column("workstreams", "concurrency_mode")
op.drop_column("workstreams", "launch_mode")
op.drop_column("workstreams", "execution_state")

View File

@@ -806,3 +806,139 @@ class TestReconciliationEndpoints:
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "todo"
class TestExecutionQueueEndpoints:
async def test_execution_semantics_separates_state_hub_and_activity_core(self, client):
r = await client.get("/execution/semantics")
assert r.status_code == 200
body = r.json()
assert "queued" in body["execution_states"]
assert "immediate" in body["launch_modes"]
assert "parallel" in body["concurrency_modes"]
assert any("launch requests" in item for item in body["state_hub_responsibility"])
assert any("dispatch" in item for item in body["activity_core_responsibility"])
async def test_execution_intent_update_does_not_change_lifecycle_status(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], status="ready")
r = await client.patch(f"/execution/workstreams/{ws['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"concurrency_mode": "parallel",
"queue_rank": 7,
"execution_group": "ui-state",
})
assert r.status_code == 200, r.text
body = r.json()
assert body["execution_state"] == "queued"
assert body["launch_mode"] == "queued"
assert body["concurrency_mode"] == "parallel"
assert body["queue_rank"] == 7
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "ready"
assert r.json()["execution_state"] == "queued"
async def test_workplan_stack_orders_eligible_queued_work_before_blocked(self, client):
await _create_domain(client)
topic = await _create_topic(client)
queued = await _create_workstream(
client,
topic["id"],
slug="queued-wp",
status="ready",
planning_priority="high",
planning_order=2,
)
blocked = await _create_workstream(
client,
topic["id"],
slug="blocked-wp",
status="ready",
planning_priority="high",
planning_order=1,
)
lifecycle_blocked = await _create_workstream(
client,
topic["id"],
slug="lifecycle-blocked-wp",
status="blocked",
planning_priority="high",
planning_order=0,
)
dependency = await _create_workstream(
client,
topic["id"],
slug="dependency-wp",
status="active",
planning_priority="low",
planning_order=3,
)
await client.patch(f"/execution/workstreams/{queued['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"queue_rank": 2,
})
await client.patch(f"/execution/workstreams/{blocked['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"queue_rank": 1,
})
await client.patch(f"/execution/workstreams/{lifecycle_blocked['id']}/intent", json={
"execution_state": "queued",
"launch_mode": "queued",
"queue_rank": 0,
})
await client.post(
f"/workstreams/{blocked['id']}/dependencies/",
json={"to_workstream_id": dependency["id"], "description": "wait"},
)
r = await client.get("/execution/workplan-stack?include_manual=false")
assert r.status_code == 200, r.text
rows = r.json()
assert [row["slug"] for row in rows] == ["queued-wp", "lifecycle-blocked-wp", "blocked-wp"]
assert rows[0]["eligible"] is True
assert rows[1]["eligible"] is False
assert rows[1]["status"] == "blocked"
assert rows[2]["eligible"] is False
assert rows[2]["blocked_by_workstream_ids"] == [dependency["id"]]
async def test_launch_request_records_handoff_and_updates_execution_intent(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], status="ready")
r = await client.post("/execution/launch-requests", json={
"workstream_id": ws["id"],
"requested_by": "dashboard",
"requested_actor": "activity-core",
"launch_mode": "immediate",
"concurrency_mode": "sequential",
"priority": "high",
"branch_preference": "codex/state-wp-0049",
"immediate_pickup": True,
"notes": "start now",
})
assert r.status_code == 201, r.text
body = r.json()
assert body["workstream_id"] == ws["id"]
assert body["launch_mode"] == "immediate"
assert body["immediate_pickup"] is True
assert body["status"] == "requested"
r = await client.get(f"/workstreams/{ws['id']}")
updated = r.json()
assert updated["status"] == "ready"
assert updated["execution_state"] == "launching"
assert updated["launch_mode"] == "immediate"
r = await client.get(f"/execution/launch-requests?workstream_id={ws['id']}")
assert len(r.json()) == 1

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Workplan Execution Queue and Activity-Core Handoff"
domain: custodian
repo: state-hub
status: proposed
status: finished
owner: codex
topic_slug: custodian
planning_priority: medium
@@ -38,7 +38,7 @@ automations, and agent dispatch.
```task
id: STATE-WP-0049-T01
status: todo
status: done
priority: high
state_hub_task_id: "b69e3c7d-46be-4e88-b039-7aa82d653b53"
```
@@ -49,11 +49,17 @@ sequential, and parallel intent.
Done when queue semantics are documented without overloading lifecycle status.
Result 2026-05-23: added execution semantics for `manual`, `queued`,
`scheduled`, `launching`, `paused`, `completed`, and `cancelled` execution
states; `manual`, `queued`, `scheduled`, and `immediate` launch modes; and
`sequential`/`parallel` concurrency modes. The `/execution/semantics` endpoint
exposes the vocabulary and boundary notes.
## T02 - Separate Lifecycle From Execution Intent
```task
id: STATE-WP-0049-T02
status: todo
status: done
priority: high
state_hub_task_id: "4f48b4d4-7232-4e36-b4a2-03aaef684ab4"
```
@@ -63,11 +69,16 @@ execution intent lives in separate planning/execution fields.
Done when `active` no longer has to imply "dispatch a coding agent right now."
Result 2026-05-23: added separate execution intent fields on workstreams:
`execution_state`, `launch_mode`, `concurrency_mode`, `queue_rank`,
`execution_group`, and `scheduled_for`. Lifecycle `status` remains unchanged
when execution intent is updated.
## T03 - Add Prioritized Workplan Stack
```task
id: STATE-WP-0049-T03
status: todo
status: done
priority: high
state_hub_task_id: "9237b778-4c83-4ebe-942e-79bae29aaa45"
```
@@ -78,11 +89,15 @@ optional concurrency grouping.
Done when a human can choose what starts next from a clear ordered list.
Result 2026-05-23: added `/execution/workplan-stack`, a dependency-aware
ordered queue view using execution state, planning priority, queue rank,
planning order, lifecycle status, and workstream/task blockers.
## T04 - Add Launch Request Records
```task
id: STATE-WP-0049-T04
status: todo
status: done
priority: medium
state_hub_task_id: "a8366495-fd66-436e-b423-e24c3ffadbc9"
```
@@ -94,11 +109,16 @@ preference, and whether immediate agent pickup is desired.
Done when UI/API actions can request implementation without losing intent in a
plain status patch.
Result 2026-05-23: added durable `workplan_launch_requests` records and
`POST /execution/launch-requests`. Launch requests capture requested actor,
mode, priority, repo, branch preference, immediate pickup, notes, and metadata
while updating execution intent separately from lifecycle status.
## T05 - Define Activity-Core Boundary
```task
id: STATE-WP-0049-T05
status: todo
status: done
priority: high
state_hub_task_id: "2ebf5186-9a92-476a-ac62-3588cc51fb31"
```
@@ -110,11 +130,15 @@ own scheduling, wakeups, and agent dispatch when available.
Done when the boundary is explicit enough that implementation can proceed in
State Hub now without trapping orchestration there permanently.
Result 2026-05-23: the execution semantics service defines State Hub as the
state, ranking, and durable handoff owner, while activity-core owns schedules,
wakeups, agent dispatch, and request execution once available.
## T06 - Dashboard Controls
```task
id: STATE-WP-0049-T06
status: todo
status: done
priority: medium
state_hub_task_id: "7d1c9ec7-8130-4f26-92ae-754c6f28ea48"
```
@@ -125,11 +149,15 @@ intent, and manual versus immediate pickup.
Done when a user can move a workplan into the queue without accidentally
starting work, or explicitly request immediate pickup when desired.
Result 2026-05-23: added the dashboard Workplan Queue page with queue rank,
launch mode, sequential/parallel intent, execution group, save, and request
controls. Workstreams link to the queue page.
## T07 - Handoff And Scheduling Tests
```task
id: STATE-WP-0049-T07
status: todo
status: done
priority: medium
state_hub_task_id: "f819907b-0409-411e-82d1-564c2d543f86"
```
@@ -139,6 +167,10 @@ eligibility, and the State Hub/activity-core boundary contract.
Done when execution intent can evolve without contaminating lifecycle state.
Result 2026-05-23: added API tests for semantics, execution-intent updates,
dependency-aware queue ordering, and launch-request creation. The tests pin
that lifecycle status remains separate from execution intent.
## Acceptance Criteria
- Workplan lifecycle status is separate from execution intent.