Skip to content

Commit bdd92cb

Browse files
mjunaidcaclaude
andcommitted
fix(api): comprehensive async SQLAlchemy MissingGreenlet fix
Root cause: log_action() was calling session.commit() after each audit log entry, which detached all SQLAlchemy objects. Subsequent access to detached object attributes triggered lazy loading in async context, causing MissingGreenlet errors. Solution: - audit.py: Remove commit from log_action(), add actor_type param - All routers: Extract primitive values (worker_id, worker_type) BEFORE any commits, use session.flush() for IDs, single commit at end of operations - tasks.py assign_task: Build TaskRead directly with extracted assignee_handle instead of passing detached Worker object All 41 tests pass, all endpoints manually verified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 48ea060 commit bdd92cb

5 files changed

Lines changed: 284 additions & 131 deletions

File tree

packages/api/src/taskflow_api/routers/agents.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ async def list_agents(
2323
offset: int = Query(default=0, ge=0),
2424
) -> list[WorkerRead]:
2525
"""List all global agents."""
26-
# Ensure user is set up
2726
await ensure_user_setup(session, user)
2827

2928
stmt = (
@@ -58,6 +57,9 @@ async def create_agent(
5857
) -> WorkerRead:
5958
"""Register a global agent."""
6059
current_worker = await ensure_user_setup(session, user)
60+
# Extract primitive values before any commits
61+
current_worker_id = current_worker.id
62+
current_worker_type = current_worker.type
6163

6264
# Check handle uniqueness
6365
stmt = select(Worker).where(Worker.handle == data.handle)
@@ -74,23 +76,27 @@ async def create_agent(
7476
capabilities=data.capabilities,
7577
)
7678
session.add(agent)
77-
await session.commit()
78-
await session.refresh(agent)
79+
await session.flush() # Get agent.id without committing
7980

80-
# Audit log
81+
# Audit log (doesn't commit)
8182
await log_action(
8283
session,
8384
entity_type="worker",
8485
entity_id=agent.id,
8586
action="created",
86-
actor_id=current_worker.id,
87+
actor_id=current_worker_id,
88+
actor_type=current_worker_type,
8789
details={
88-
"handle": agent.handle,
89-
"agent_type": agent.agent_type,
90-
"capabilities": agent.capabilities,
90+
"handle": data.handle,
91+
"agent_type": data.agent_type,
92+
"capabilities": data.capabilities,
9193
},
9294
)
9395

96+
# Single commit for all changes
97+
await session.commit()
98+
await session.refresh(agent)
99+
94100
return WorkerRead(
95101
id=agent.id,
96102
handle=agent.handle,
@@ -137,6 +143,8 @@ async def update_agent(
137143
) -> WorkerRead:
138144
"""Update agent details."""
139145
current_worker = await ensure_user_setup(session, user)
146+
current_worker_id = current_worker.id
147+
current_worker_type = current_worker.type
140148

141149
agent = await session.get(Worker, agent_id)
142150
if not agent:
@@ -158,19 +166,21 @@ async def update_agent(
158166

159167
if changes:
160168
session.add(agent)
161-
await session.commit()
162-
await session.refresh(agent)
163169

164170
# Audit log
165171
await log_action(
166172
session,
167173
entity_type="worker",
168-
entity_id=agent.id,
174+
entity_id=agent_id,
169175
action="updated",
170-
actor_id=current_worker.id,
176+
actor_id=current_worker_id,
177+
actor_type=current_worker_type,
171178
details=changes,
172179
)
173180

181+
await session.commit()
182+
await session.refresh(agent)
183+
174184
return WorkerRead(
175185
id=agent.id,
176186
handle=agent.handle,
@@ -190,13 +200,19 @@ async def delete_agent(
190200
) -> dict:
191201
"""Delete an agent."""
192202
current_worker = await ensure_user_setup(session, user)
203+
current_worker_id = current_worker.id
204+
current_worker_type = current_worker.type
193205

194206
agent = await session.get(Worker, agent_id)
195207
if not agent:
196208
raise HTTPException(status_code=404, detail="Agent not found")
197209
if agent.type != "agent":
198210
raise HTTPException(status_code=404, detail="Not an agent")
199211

212+
# Extract values before deletion
213+
agent_handle = agent.handle
214+
agent_type_val = agent.agent_type
215+
200216
# Check if agent is member of any project
201217
stmt = select(ProjectMember).where(ProjectMember.worker_id == agent_id)
202218
result = await session.exec(stmt)
@@ -210,10 +226,11 @@ async def delete_agent(
210226
await log_action(
211227
session,
212228
entity_type="worker",
213-
entity_id=agent.id,
229+
entity_id=agent_id,
214230
action="deleted",
215-
actor_id=current_worker.id,
216-
details={"handle": agent.handle, "agent_type": agent.agent_type},
231+
actor_id=current_worker_id,
232+
actor_type=current_worker_type,
233+
details={"handle": agent_handle, "agent_type": agent_type_val},
217234
)
218235

219236
await session.delete(agent)

packages/api/src/taskflow_api/routers/members.py

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ async def list_members(
2323
) -> list[MemberRead]:
2424
"""List all members of a project (humans + agents)."""
2525
worker = await ensure_user_setup(session, user)
26+
worker_id = worker.id
2627

2728
# Check project exists
2829
project = await session.get(Project, project_id)
@@ -32,7 +33,7 @@ async def list_members(
3233
# Check user is member
3334
stmt = select(ProjectMember).where(
3435
ProjectMember.project_id == project_id,
35-
ProjectMember.worker_id == worker.id,
36+
ProjectMember.worker_id == worker_id,
3637
)
3738
result = await session.exec(stmt)
3839
if not result.first():
@@ -73,6 +74,8 @@ async def add_member(
7374
- agent_id: Existing agent worker ID (links to project)
7475
"""
7576
current_worker = await ensure_user_setup(session, user)
77+
current_worker_id = current_worker.id
78+
current_worker_type = current_worker.type
7679

7780
# Validate input
7881
if not data.user_id and not data.agent_id:
@@ -90,6 +93,7 @@ async def add_member(
9093
raise HTTPException(status_code=403, detail="Only project owner can add members")
9194

9295
member_worker: Worker | None = None
96+
member_worker_id: int | None = None
9397

9498
if data.agent_id:
9599
# Link existing agent
@@ -98,14 +102,17 @@ async def add_member(
98102
raise HTTPException(status_code=404, detail="Agent not found")
99103
if member_worker.type != "agent":
100104
raise HTTPException(status_code=400, detail="Worker is not an agent")
105+
member_worker_id = member_worker.id
101106

102107
elif data.user_id:
103108
# Get or create worker for SSO user
104109
stmt = select(Worker).where(Worker.user_id == data.user_id)
105110
result = await session.exec(stmt)
106111
member_worker = result.first()
107112

108-
if not member_worker:
113+
if member_worker:
114+
member_worker_id = member_worker.id
115+
else:
109116
# Create worker for new user
110117
# We don't have their email/name, so use user_id
111118
handle = f"@user-{data.user_id[:8].lower()}"
@@ -128,16 +135,13 @@ async def add_member(
128135
user_id=data.user_id,
129136
)
130137
session.add(member_worker)
131-
await session.commit()
132-
await session.refresh(member_worker)
133-
134-
# Refresh member_worker to ensure it's attached to session after any commits
135-
await session.refresh(member_worker)
138+
await session.flush() # Get ID without committing
139+
member_worker_id = member_worker.id
136140

137141
# Check not already a member
138142
stmt = select(ProjectMember).where(
139143
ProjectMember.project_id == project_id,
140-
ProjectMember.worker_id == member_worker.id,
144+
ProjectMember.worker_id == member_worker_id,
141145
)
142146
result = await session.exec(stmt)
143147
if result.first():
@@ -146,33 +150,41 @@ async def add_member(
146150
# Add membership
147151
membership = ProjectMember(
148152
project_id=project_id,
149-
worker_id=member_worker.id,
153+
worker_id=member_worker_id,
150154
role="member",
151155
)
152156
session.add(membership)
153-
await session.commit()
154-
await session.refresh(membership)
155157

156-
# Audit log
158+
# Get member details for response before commit
159+
member_handle = member_worker.handle
160+
member_name = member_worker.name
161+
member_type = member_worker.type
162+
163+
# Audit log (doesn't commit)
157164
await log_action(
158165
session,
159166
entity_type="project",
160167
entity_id=project_id,
161168
action="member_added",
162-
actor_id=current_worker.id,
169+
actor_id=current_worker_id,
170+
actor_type=current_worker_type,
163171
details={
164-
"worker_id": member_worker.id,
165-
"handle": member_worker.handle,
166-
"type": member_worker.type,
172+
"worker_id": member_worker_id,
173+
"handle": member_handle,
174+
"type": member_type,
167175
},
168176
)
169177

178+
# Single commit
179+
await session.commit()
180+
await session.refresh(membership)
181+
170182
return MemberRead(
171183
id=membership.id,
172-
worker_id=member_worker.id,
173-
handle=member_worker.handle,
174-
name=member_worker.name,
175-
type=member_worker.type,
184+
worker_id=member_worker_id,
185+
handle=member_handle,
186+
name=member_name,
187+
type=member_type,
176188
role=membership.role,
177189
joined_at=membership.joined_at,
178190
)
@@ -187,6 +199,8 @@ async def remove_member(
187199
) -> dict:
188200
"""Remove a member from a project."""
189201
current_worker = await ensure_user_setup(session, user)
202+
current_worker_id = current_worker.id
203+
current_worker_type = current_worker.type
190204

191205
# Check project exists
192206
project = await session.get(Project, project_id)
@@ -206,24 +220,27 @@ async def remove_member(
206220
if membership.role == "owner":
207221
raise HTTPException(status_code=400, detail="Cannot remove project owner")
208222

209-
# Get worker for audit
210-
member_worker = await session.get(Worker, membership.worker_id)
223+
# Get worker info for audit before deletion
224+
removed_worker_id = membership.worker_id
225+
member_worker = await session.get(Worker, removed_worker_id)
226+
member_handle = member_worker.handle if member_worker else None
211227

212-
# Remove membership
213-
await session.delete(membership)
214-
await session.commit()
215-
216-
# Audit log
228+
# Audit log before deletion
217229
await log_action(
218230
session,
219231
entity_type="project",
220232
entity_id=project_id,
221233
action="member_removed",
222-
actor_id=current_worker.id,
234+
actor_id=current_worker_id,
235+
actor_type=current_worker_type,
223236
details={
224-
"worker_id": member_worker.id if member_worker else None,
225-
"handle": member_worker.handle if member_worker else None,
237+
"worker_id": removed_worker_id,
238+
"handle": member_handle,
226239
},
227240
)
228241

242+
# Remove membership
243+
await session.delete(membership)
244+
await session.commit()
245+
229246
return {"ok": True}

0 commit comments

Comments
 (0)