feat(agents): Update task comment requirements and add in_progress_at tracking
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
"""add task in_progress_at
|
||||||
|
|
||||||
|
Revision ID: c1a2b3c4d5e7
|
||||||
|
Revises: b9d22e2a4d50
|
||||||
|
Create Date: 2026-02-04 13:34:25.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c1a2b3c4d5e7"
|
||||||
|
down_revision = "b9d22e2a4d50"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS in_progress_at TIMESTAMP WITHOUT TIME ZONE"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_tasks_in_progress_at ON tasks (in_progress_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_tasks_in_progress_at")
|
||||||
|
op.execute("ALTER TABLE tasks DROP COLUMN IF EXISTS in_progress_at")
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import asc
|
from sqlalchemy import asc, desc
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
@@ -30,6 +31,42 @@ from app.services.activity_log import record_activity
|
|||||||
|
|
||||||
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_markdown_comment(message: str) -> bool:
|
||||||
|
content = message.strip()
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
lowered = content.lower()
|
||||||
|
if not all(field in lowered for field in REQUIRED_COMMENT_FIELDS):
|
||||||
|
return False
|
||||||
|
if "- " not in content and "* " not in content:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_recent_comment(
|
||||||
|
session: Session,
|
||||||
|
task: Task,
|
||||||
|
agent_id: UUID | None,
|
||||||
|
since: datetime | None,
|
||||||
|
) -> bool:
|
||||||
|
if agent_id is None or since is None:
|
||||||
|
return False
|
||||||
|
statement = (
|
||||||
|
select(ActivityEvent)
|
||||||
|
.where(col(ActivityEvent.task_id) == task.id)
|
||||||
|
.where(col(ActivityEvent.event_type) == "task.comment")
|
||||||
|
.where(col(ActivityEvent.agent_id) == agent_id)
|
||||||
|
.where(col(ActivityEvent.created_at) >= since)
|
||||||
|
.order_by(desc(col(ActivityEvent.created_at)))
|
||||||
|
)
|
||||||
|
event = session.exec(statement).first()
|
||||||
|
if event is None or event.message is None:
|
||||||
|
return False
|
||||||
|
return is_valid_markdown_comment(event.message)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TaskRead])
|
@router.get("", response_model=list[TaskRead])
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
@@ -74,20 +111,28 @@ def update_task(
|
|||||||
) -> Task:
|
) -> Task:
|
||||||
previous_status = task.status
|
previous_status = task.status
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
comment = updates.pop("comment", None)
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
if actor.agent and actor.agent.board_id and task.board_id:
|
if actor.agent and actor.agent.board_id and task.board_id:
|
||||||
if actor.agent.board_id != task.board_id:
|
if actor.agent.board_id != task.board_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
allowed_fields = {"status"}
|
allowed_fields = {"status", "comment"}
|
||||||
if not set(updates).issubset(allowed_fields):
|
if not set(updates).issubset(allowed_fields):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if "status" in updates:
|
if "status" in updates:
|
||||||
if updates["status"] == "inbox":
|
if updates["status"] == "inbox":
|
||||||
task.assigned_agent_id = None
|
task.assigned_agent_id = None
|
||||||
|
task.in_progress_at = None
|
||||||
else:
|
else:
|
||||||
task.assigned_agent_id = actor.agent.id if actor.agent else None
|
task.assigned_agent_id = actor.agent.id if actor.agent else None
|
||||||
elif "status" in updates and updates["status"] == "inbox":
|
if updates["status"] == "in_progress":
|
||||||
task.assigned_agent_id = None
|
task.in_progress_at = datetime.utcnow()
|
||||||
|
elif "status" in updates:
|
||||||
|
if updates["status"] == "inbox":
|
||||||
|
task.assigned_agent_id = None
|
||||||
|
task.in_progress_at = None
|
||||||
|
elif updates["status"] == "in_progress":
|
||||||
|
task.in_progress_at = datetime.utcnow()
|
||||||
if "assigned_agent_id" in updates and updates["assigned_agent_id"]:
|
if "assigned_agent_id" in updates and updates["assigned_agent_id"]:
|
||||||
agent = session.get(Agent, updates["assigned_agent_id"])
|
agent = session.get(Agent, updates["assigned_agent_id"])
|
||||||
if agent is None:
|
if agent is None:
|
||||||
@@ -98,10 +143,35 @@ def update_task(
|
|||||||
setattr(task, key, value)
|
setattr(task, key, value)
|
||||||
task.updated_at = datetime.utcnow()
|
task.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if "status" in updates and updates["status"] == "review":
|
||||||
|
if comment is not None and comment.strip():
|
||||||
|
if not is_valid_markdown_comment(comment):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
else:
|
||||||
|
if not has_valid_recent_comment(
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
task.assigned_agent_id,
|
||||||
|
task.in_progress_at,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(task)
|
session.refresh(task)
|
||||||
|
|
||||||
|
if comment is not None and comment.strip():
|
||||||
|
if actor.actor_type == "agent" and not is_valid_markdown_comment(comment):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
event = ActivityEvent(
|
||||||
|
event_type="task.comment",
|
||||||
|
message=comment.strip(),
|
||||||
|
task_id=task.id,
|
||||||
|
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
if "status" in updates and task.status != previous_status:
|
if "status" in updates and task.status != previous_status:
|
||||||
event_type = "task.status_changed"
|
event_type = "task.status_changed"
|
||||||
message = f"Task moved to {task.status}: {task.title}."
|
message = f"Task moved to {task.status}: {task.title}."
|
||||||
@@ -160,6 +230,8 @@ def create_task_comment(
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if not payload.message.strip():
|
if not payload.message.strip():
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
if actor.actor_type == "agent" and not is_valid_markdown_comment(payload.message):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
event = ActivityEvent(
|
event = ActivityEvent(
|
||||||
event_type="task.comment",
|
event_type="task.comment",
|
||||||
message=payload.message.strip(),
|
message=payload.message.strip(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Task(TenantScoped, table=True):
|
|||||||
status: str = Field(default="inbox", index=True)
|
status: str = Field(default="inbox", index=True)
|
||||||
priority: str = Field(default="medium", index=True)
|
priority: str = Field(default="medium", index=True)
|
||||||
due_at: datetime | None = None
|
due_at: datetime | None = None
|
||||||
|
in_progress_at: datetime | None = None
|
||||||
|
|
||||||
created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
|
created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
assigned_agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
|
assigned_agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ class TaskUpdate(SQLModel):
|
|||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
due_at: datetime | None = None
|
due_at: datetime | None = None
|
||||||
assigned_agent_id: UUID | None = None
|
assigned_agent_id: UUID | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskRead(TaskBase):
|
class TaskRead(TaskBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
board_id: UUID | None
|
board_id: UUID | None
|
||||||
created_by_user_id: UUID | None
|
created_by_user_id: UUID | None
|
||||||
|
in_progress_at: datetime | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -30,5 +30,12 @@ Write things down. Do not rely on short-term context.
|
|||||||
- HEARTBEAT.md defines what to do on each heartbeat.
|
- HEARTBEAT.md defines what to do on each heartbeat.
|
||||||
|
|
||||||
## Task updates
|
## Task updates
|
||||||
- Log all task progress and results via the task comments endpoint.
|
- All task updates MUST be posted to the task comments endpoint.
|
||||||
- Do not post task updates in chat/web channels.
|
- Do not post task updates in chat/web channels under any circumstance.
|
||||||
|
- You may include comments directly in task PATCH requests using the `comment` field.
|
||||||
|
- Required comment fields (markdown):
|
||||||
|
- `status`: inbox | in_progress | review | done
|
||||||
|
- `summary`: one line
|
||||||
|
- `details`: 1–3 bullets
|
||||||
|
- `next`: next step or handoff request
|
||||||
|
- Every status change must include a comment within 30 seconds (see HEARTBEAT.md).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ On startup:
|
|||||||
1) Verify API reachability (GET {{ base_url }}/api/v1/gateway/status).
|
1) Verify API reachability (GET {{ base_url }}/api/v1/gateway/status).
|
||||||
- A 401 Unauthorized response is acceptable here for agents (auth-protected endpoint).
|
- A 401 Unauthorized response is acceptable here for agents (auth-protected endpoint).
|
||||||
2) Connect to Mission Control once by sending a heartbeat check-in.
|
2) Connect to Mission Control once by sending a heartbeat check-in.
|
||||||
2a) Use task comments for updates; do not send task updates to chat/web.
|
2a) Use task comments for all updates; do not send task updates to chat/web.
|
||||||
|
2b) Follow the required comment format in AGENTS.md / HEARTBEAT.md.
|
||||||
3) If you send a boot message, end with NO_REPLY.
|
3) If you send a boot message, end with NO_REPLY.
|
||||||
4) If BOOTSTRAP.md exists in this workspace, the agent should run it once and delete it.
|
4) If BOOTSTRAP.md exists in this workspace, the agent should run it once and delete it.
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
|
|||||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Commenting rules (mandatory)
|
||||||
|
- Every task state change MUST be followed by a task comment within 30 seconds.
|
||||||
|
- Never post task updates to chat/web channels. Task comments are the only update channel.
|
||||||
|
- Minimum comment format:
|
||||||
|
- `status`: inbox | in_progress | review | done
|
||||||
|
- `summary`: one-line progress update
|
||||||
|
- `details`: 1–3 bullets of what changed / what you did
|
||||||
|
- `next`: next step or handoff request
|
||||||
|
|
||||||
2) List boards:
|
2) List boards:
|
||||||
```bash
|
```bash
|
||||||
curl -s "$BASE_URL/api/v1/boards" \
|
curl -s "$BASE_URL/api/v1/boards" \
|
||||||
@@ -40,25 +49,29 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks" \
|
|||||||
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "in_progress"}'
|
-d '{"status": "in_progress", "comment": "[status=in_progress] Claimed by '$AGENT_NAME'.\\nsummary: Starting work.\\ndetails: - Triage task and plan approach.\\nnext: Begin execution."}'
|
||||||
```
|
```
|
||||||
|
|
||||||
5) Work the task:
|
5) Work the task:
|
||||||
- Update status as you progress.
|
- Update status as you progress.
|
||||||
- Post a brief work log to the task comments endpoint (do not use chat).
|
- Post a brief work log to the task comments endpoint (do not use chat).
|
||||||
- When complete, move to "review":
|
- When complete, use the following mandatory steps:
|
||||||
|
|
||||||
|
5a) Post the completion comment (required, markdown). Include:
|
||||||
|
- status, summary, details (bullets), next, and the full response text.
|
||||||
|
Use the task comments endpoint for this step.
|
||||||
|
|
||||||
|
5b) Move the task to "review":
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "review"}'
|
-d '{"status": "review"}'
|
||||||
```
|
```
|
||||||
```bash
|
|
||||||
curl -s -X POST "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}/comments" \
|
## Definition of Done
|
||||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
- A task is not complete until the draft/response is posted as a task comment.
|
||||||
-H "Content-Type: application/json" \
|
- Comments must be markdown and include: summary, details (bullets), next.
|
||||||
-d '{"message": "Summary of work, result, and next steps."}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Status flow
|
## Status flow
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user