fuli-context 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-ByCUor-j.js → AboutPage-BcZeFcHe.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-DkdkZ40Q.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CYAjAb9J.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-CV4F54R_.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-z4u5mXOG.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-BWOfeS6k.js → OverviewPage-Xyfm5xkZ.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-c3w4-h7a.js → PersonalProfilePage-DB9mByB3.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-DdH0oGB3.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-BYIpcIHG.js → ReviewPage-CQYGwFBH.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
  23. package/dist/web/assets/{SettingsPage-2VkbXgZi.js → SettingsPage-BlEZ03Yi.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-BOPQZuva.js → WritingTastePage-DvRt_nmP.js} +1 -1
  25. package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
  26. package/dist/web/assets/{console-BlAhujsH.js → console-tOxOl8Vq.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
  28. package/dist/web/assets/{i18n-Ci3heuiL.js → i18n-CgBHZthI.js} +3 -3
  29. package/dist/web/assets/{identity-4_-v6cB7.js → identity-CFo_Uw1H.js} +1 -1
  30. package/dist/web/assets/index-CJc5ppXH.css +1 -0
  31. package/dist/web/assets/index-yCi3cz2l.js +2 -0
  32. package/dist/web/assets/{model-yG0YqUyZ.js → model-BtnVr7fX.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-twhdZArA.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
  34. package/dist/web/assets/{vue-router-C-wot_Di.js → vue-router-CzHm-y-x.js} +1 -1
  35. package/dist/web/index.html +7 -6
  36. package/docs/employee-agents.md +99 -0
  37. package/docs/project-agent-memory.md +479 -0
  38. package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
  39. package/graph-provider/fuli_graph/model_base.py +5 -0
  40. package/graph-provider/fuli_graph/model_validation.py +32 -0
  41. package/graph-provider/fuli_graph/models.py +26 -170
  42. package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
  43. package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -0
  44. package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
  45. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  46. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  47. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  48. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  49. package/graph-provider/fuli_graph/project_agent_routes.py +50 -1
  50. package/graph-provider/fuli_graph/project_agent_task_models.py +220 -29
  51. package/graph-provider/fuli_graph/runtime.py +37 -1
  52. package/graph-provider/fuli_graph/store.py +6 -0
  53. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  54. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +122 -0
  55. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  56. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  57. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  58. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  60. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  61. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  62. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +756 -164
  63. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +396 -7
  64. package/graph-provider/fuli_graph/store_project_agent_tasks.py +265 -465
  65. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  66. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  67. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  68. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  69. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  70. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  71. package/graph-provider/pyproject.toml +1 -1
  72. package/npm-shrinkwrap.json +8 -8
  73. package/package.json +5 -2
  74. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  75. package/src/adaptive-runtime/executor-pool.js +46 -5
  76. package/src/adaptive-runtime/graph-services.js +10 -3
  77. package/src/adaptive-runtime/lease-client.js +66 -14
  78. package/src/adaptive-runtime/runtime-broker.js +24 -9
  79. package/src/agent-tools/employee-definitions.js +43 -0
  80. package/src/agent-tools/graph-definitions.js +48 -6
  81. package/src/agent-tools/graph-handlers.js +15 -0
  82. package/src/agent-tools/project-agent-definitions.js +35 -4
  83. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  84. package/src/agent-tools.js +6 -2
  85. package/src/agents/codex/lifecycle-hook.js +107 -0
  86. package/src/agents/codex/lifecycle-hooks.js +231 -0
  87. package/src/agents/cursor/lifecycle-hook.js +133 -0
  88. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  89. package/src/agents/lifecycle-message.js +19 -0
  90. package/src/app/agent-request-context.js +23 -0
  91. package/src/cli/command-registry.js +2 -0
  92. package/src/cli/employee-command.js +17 -0
  93. package/src/cli/remote-mcp-command.js +282 -0
  94. package/src/cli/setup-command.js +5 -0
  95. package/src/cli.js +10 -0
  96. package/src/employees/catalog/jefa.json +21 -0
  97. package/src/employees/install-package.js +77 -0
  98. package/src/employees/management-policy.js +88 -0
  99. package/src/employees/manifest.js +53 -0
  100. package/src/employees/package-registry.js +107 -0
  101. package/src/employees/project-selection.js +50 -0
  102. package/src/employees/recruitment.js +199 -0
  103. package/src/employees/service.js +83 -0
  104. package/src/employees/task-entry.js +39 -0
  105. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  106. package/src/graphiti/federated-application.js +78 -35
  107. package/src/graphiti/project-agent-control-plane.js +32 -3
  108. package/src/graphiti/project-agent-mapping.js +175 -4
  109. package/src/graphiti/project-agent-memory.js +115 -0
  110. package/src/graphiti/project-agent-task-entry.js +126 -0
  111. package/src/graphiti/project-agent-workflows.js +49 -14
  112. package/src/graphiti/provider-client.js +78 -4
  113. package/src/http/api-router.js +25 -5
  114. package/src/http/employee-api-router.js +64 -0
  115. package/src/http/error-mapping.js +4 -0
  116. package/src/http/graph-api-router.js +14 -0
  117. package/src/http/request-policy.js +6 -3
  118. package/src/http/static-handler.js +4 -3
  119. package/src/mcp/create-mcp-server.js +98 -37
  120. package/src/mcp/direct-call.js +38 -0
  121. package/src/mcp/instructions.js +8 -7
  122. package/src/mcp/provider-task-context-registry.js +74 -0
  123. package/src/mcp/remote-http-server.js +761 -0
  124. package/src/mcp/session-id.js +29 -3
  125. package/src/mcp/task-context-registry.js +88 -29
  126. package/src/mcp/tool-annotations.js +12 -2
  127. package/src/mcp/tool-result.js +8 -3
  128. package/src/mcp-server.js +20 -8
  129. package/src/native-runtime/runtime.js +58 -8
  130. package/src/server.js +76 -28
  131. package/src/setup/agent-installation-status.js +38 -6
  132. package/src/setup/agents.js +20 -8
  133. package/src/setup/claude-code-config.js +55 -13
  134. package/src/setup/codex-bootstrap.js +11 -10
  135. package/src/setup/codex-config.js +74 -7
  136. package/src/setup/config-backup.js +18 -2
  137. package/src/setup/cursor-config.js +22 -5
  138. package/src/setup/setup.js +30 -22
  139. package/src/storage/json-file.js +11 -3
  140. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +0 -1
  141. package/dist/web/assets/KnowledgePage-vYYXipOi.js +0 -1
  142. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +0 -1
  143. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +0 -1
  144. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +0 -11
  145. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +0 -1
  146. package/dist/web/assets/conversation-launcher-settings-DTYxiLJt.js +0 -1
  147. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  148. package/dist/web/assets/index-C67Q0JY2.js +0 -2
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from datetime import timedelta
7
+ from uuid import uuid4
6
8
 
7
9
  from fastapi import HTTPException
8
10
 
@@ -13,6 +15,7 @@ from .project_agent_models import (
13
15
  ProjectAgentProfile,
14
16
  ProjectAgentUpsert,
15
17
  )
18
+ from .project_agent_recruitment_values import project_agent_recruitment_record
16
19
  from .project_agent_task_models import (
17
20
  ProjectAgentRecruitmentDecision,
18
21
  ProjectAgentRecruitmentPolicyRecord,
@@ -21,6 +24,10 @@ from .project_agent_task_models import (
21
24
  ProjectAgentRoutingDecisionRecord,
22
25
  )
23
26
  from .provider_values import native_datetime, now_utc, stable_uuid
27
+ from .store_transactions import query_store_transaction
28
+
29
+
30
+ RECRUITMENT_CLAIM_TTL = timedelta(minutes=2)
24
31
 
25
32
 
26
33
  class StoreProjectAgentTaskRecruitment:
@@ -97,22 +104,90 @@ class StoreProjectAgentTaskRecruitment:
97
104
  space,
98
105
  request.personal_project_id,
99
106
  )
107
+ updated_at = now_utc()
108
+ decision_status = (
109
+ 'cancelled' if request.decision == 'cancel' else 'requested'
110
+ )
111
+ provisioning_claim_id = (
112
+ str(uuid4()) if decision_status == 'requested' else None
113
+ )
114
+ cancel_event_id = stable_uuid(
115
+ self.settings.provider_id,
116
+ request.personal_space_id,
117
+ 'project-agent-task-event',
118
+ 'recruitment-cancelled',
119
+ request.recruitment_id,
120
+ )
100
121
  records, _, _ = await self.runtime.driver.execute_query(
101
122
  '''
102
- MATCH (recruitment:FuliProjectAgentRecruitment {
103
- personal_space_id: $personal_space_id,
104
- recruitment_id: $recruitment_id,
105
- personal_project_id: $personal_project_id
106
- })
123
+ MATCH (space:FuliSpace {
124
+ id: $personal_space_id, kind: 'personal'
125
+ })-[:HAS_PROJECT_AGENT_RECRUITMENT]->
126
+ (recruitment:FuliProjectAgentRecruitment {
127
+ recruitment_id: $recruitment_id,
128
+ personal_project_id: $personal_project_id
129
+ })
130
+ MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
131
+ (task:FuliProjectAgentTask)
132
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
133
+ (hr:FuliProjectAgent {agent_type: 'hr', status: 'active'})
134
+ WHERE hr.agent_id = recruitment.hr_agent_id
135
+ WITH recruitment, task, hr
107
136
  WHERE recruitment.status = 'awaiting_confirmation'
108
- AND recruitment.revision = $expected_revision
137
+ AND coalesce(recruitment.revision, 0) = $expected_revision
138
+ AND task.task_id = recruitment.task_id
139
+ AND task.status = 'awaiting_recruitment'
140
+ AND (
141
+ task.recruitment_provisioning_claimed_at IS NULL
142
+ OR task.recruitment_provisioning_claimed_at <=
143
+ $claim_expired_before
144
+ )
145
+ AND (
146
+ $decision_status = 'cancelled'
147
+ OR hr IS NOT NULL
148
+ )
149
+ SET recruitment.status = $decision_status,
150
+ recruitment.decision_reason = $reason,
151
+ recruitment.provisioning_claim_id = $provisioning_claim_id,
152
+ recruitment.provisioning_claimed_at = $provisioning_claimed_at,
153
+ recruitment.revision = coalesce(recruitment.revision, 0) + 1,
154
+ recruitment.updated_at = $updated_at,
155
+ task.revision = coalesce(task.revision, 0) + 1,
156
+ task.updated_at = $updated_at,
157
+ task.recruitment_provisioning_claim_id =
158
+ $provisioning_claim_id,
159
+ task.recruitment_provisioning_claimed_at =
160
+ $provisioning_claimed_at
161
+ FOREACH (_ IN CASE
162
+ WHEN $decision_status = 'cancelled' THEN [1] ELSE [] END |
163
+ SET task.status = 'blocked',
164
+ task.routing_outcome = 'blocked',
165
+ task.executor_blocked_reason = $task_blocked_reason
166
+ MERGE (event:FuliProjectAgentTaskEvent {id: $cancel_event_id})
167
+ ON CREATE SET event.event_id = $cancel_event_id,
168
+ event.task_id = task.task_id,
169
+ event.status = 'blocked',
170
+ event.actor_kind = 'hr',
171
+ event.summary = $task_blocked_reason,
172
+ event.created_at = $updated_at
173
+ MERGE (task)-[:HAS_TASK_EVENT]->(event)
174
+ )
109
175
  RETURN recruitment
110
176
  ''',
111
177
  personal_space_id=request.personal_space_id,
112
178
  personal_project_id=request.personal_project_id,
113
179
  recruitment_id=request.recruitment_id,
114
180
  expected_revision=request.expected_revision,
115
- routing_='r',
181
+ decision_status=decision_status,
182
+ reason=request.reason,
183
+ provisioning_claim_id=provisioning_claim_id,
184
+ provisioning_claimed_at=(
185
+ updated_at if provisioning_claim_id else None
186
+ ),
187
+ claim_expired_before=updated_at - RECRUITMENT_CLAIM_TTL,
188
+ task_blocked_reason=f'recruitment cancelled: {request.reason}',
189
+ cancel_event_id=cancel_event_id,
190
+ updated_at=updated_at,
116
191
  )
117
192
  if not records:
118
193
  raise HTTPException(
@@ -120,40 +195,14 @@ class StoreProjectAgentTaskRecruitment:
120
195
  detail='recruitment is not awaiting confirmation or revision is stale',
121
196
  )
122
197
  raw = dict(records[0]['recruitment'])
123
- updated_at = now_utc()
124
198
  if request.decision == 'cancel':
125
- await self.runtime.driver.execute_query(
126
- '''
127
- MATCH (recruitment:FuliProjectAgentRecruitment {
128
- recruitment_id: $recruitment_id,
129
- personal_space_id: $personal_space_id
130
- })
131
- SET recruitment.status = 'cancelled',
132
- recruitment.decision_reason = $reason,
133
- recruitment.revision = recruitment.revision + 1,
134
- recruitment.updated_at = $updated_at
135
- ''',
136
- recruitment_id=request.recruitment_id,
137
- personal_space_id=request.personal_space_id,
138
- reason=request.reason,
139
- updated_at=updated_at,
140
- )
141
- await self._mark_recruitment_task_blocked(
142
- raw,
143
- request.reason,
144
- updated_at,
145
- )
146
- raw.update({
147
- 'status': 'cancelled',
148
- 'revision': int(raw.get('revision') or 0) + 1,
149
- 'updated_at': updated_at,
150
- })
151
199
  return self._recruitment(raw)
152
200
  if not raw.get('hr_agent_id'):
153
201
  raise HTTPException(
154
202
  status_code=409,
155
203
  detail='recruitment cannot proceed without an active HR Agent',
156
204
  )
205
+ raw['_guarded_task_id'] = raw['task_id']
157
206
  selected = await self._provision_recruitment(actor, raw)
158
207
  await self._activate_approved_recruitment(
159
208
  actor,
@@ -169,52 +218,14 @@ class StoreProjectAgentTaskRecruitment:
169
218
  raise HTTPException(status_code=404, detail='recruitment not found')
170
219
  return record
171
220
 
172
- async def _mark_recruitment_task_blocked(self, recruitment, reason, updated_at):
173
- event_id = stable_uuid(
174
- self.settings.provider_id,
175
- recruitment['personal_space_id'],
176
- 'project-agent-task-event',
177
- recruitment['task_id'],
178
- 'recruitment-cancelled',
179
- )
180
- await self.runtime.driver.execute_query(
181
- '''
182
- MATCH (space:FuliSpace {
183
- id: $personal_space_id, kind: 'personal'
184
- })-[:HAS_PROJECT_AGENT_TASK]->
185
- (task:FuliProjectAgentTask {task_id: $task_id})
186
- MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
187
- (recruitment:FuliProjectAgentRecruitment {
188
- recruitment_id: $recruitment_id
189
- })
190
- SET task.status = 'blocked',
191
- task.routing_outcome = 'blocked',
192
- task.executor_blocked_reason = $reason,
193
- task.updated_at = $updated_at,
194
- task.revision = coalesce(task.revision, 0) + 1
195
- MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
196
- ON CREATE SET event.event_id = $event_id,
197
- event.task_id = $task_id,
198
- event.status = 'blocked',
199
- event.actor_kind = 'hr',
200
- event.summary = $reason,
201
- event.created_at = $updated_at
202
- MERGE (task)-[:HAS_TASK_EVENT]->(event)
203
- ''',
204
- task_id=recruitment['task_id'],
205
- personal_space_id=recruitment['personal_space_id'],
206
- recruitment_id=recruitment['recruitment_id'],
207
- event_id=event_id,
208
- reason=f'recruitment cancelled: {reason}',
209
- updated_at=updated_at,
210
- )
211
-
212
221
  async def _activate_approved_recruitment(
213
222
  self,
214
223
  actor,
215
224
  recruitment,
216
225
  selected,
217
226
  approval_reason,
227
+ *,
228
+ _attempt=0,
218
229
  ):
219
230
  row = await self._find_task_row(
220
231
  recruitment['personal_space_id'],
@@ -223,6 +234,33 @@ class StoreProjectAgentTaskRecruitment:
223
234
  if not row:
224
235
  raise HTTPException(status_code=404, detail='recruitment task not found')
225
236
  raw_task = dict(row['task'])
237
+ linked = await self._link_recruited_task_participant(
238
+ recruitment,
239
+ selected,
240
+ )
241
+ if linked is False:
242
+ raise HTTPException(
243
+ status_code=409,
244
+ detail='recruited project Agent is not active',
245
+ )
246
+ task_recruitments = await self._task_recruitments(
247
+ recruitment['personal_space_id'],
248
+ recruitment['task_id'],
249
+ )
250
+ if task_recruitments and any(
251
+ item.status != 'fulfilled' for item in task_recruitments
252
+ ):
253
+ return
254
+ if raw_task.get('status') != 'awaiting_recruitment':
255
+ return
256
+
257
+ routing_selected = await self._recruitment_routing_lead(
258
+ recruitment,
259
+ selected,
260
+ raw_task,
261
+ task_recruitments,
262
+ )
263
+ expected_task_revision = int(raw_task.get('revision') or 0)
226
264
  task_model_override = raw_task.get('task_model_strategy_override_json')
227
265
  if task_model_override:
228
266
  model_strategy = ProjectAgentModelStrategy.model_validate_json(
@@ -230,7 +268,7 @@ class StoreProjectAgentTaskRecruitment:
230
268
  )
231
269
  model_source = 'task'
232
270
  else:
233
- model_strategy = selected['profile'].default_model_strategy
271
+ model_strategy = routing_selected['profile'].default_model_strategy
234
272
  model_source = 'agent'
235
273
  task_executor_override = raw_task.get(
236
274
  'task_executor_policy_override_json'
@@ -243,17 +281,17 @@ class StoreProjectAgentTaskRecruitment:
243
281
  personal_space_id=recruitment['personal_space_id'],
244
282
  personal_project_id=recruitment['personal_project_id'],
245
283
  task_id=recruitment['task_id'],
246
- agent_id=selected['agent_id'],
284
+ agent_id=routing_selected['agent_id'],
247
285
  work_kind=raw_task['work_kind'],
248
286
  required_capabilities=list(
249
- raw_task.get('required_capabilities') or []
287
+ raw_task.get('executor_capability_hints') or []
250
288
  ),
251
289
  model_strategy=model_strategy,
252
290
  task_override=(
253
291
  json.loads(task_executor_override)
254
292
  if task_executor_override else None
255
293
  ),
256
- assignment_id=selected.get('assignment_id'),
294
+ assignment_id=routing_selected.get('assignment_id'),
257
295
  model_strategy_source=model_source,
258
296
  idempotency_key=f"recruit:{recruitment['recruitment_id']}",
259
297
  )
@@ -282,7 +320,7 @@ class StoreProjectAgentTaskRecruitment:
282
320
  outcome=outcome,
283
321
  reason=recruitment['reason_code'],
284
322
  match_basis=['HR recruitment approved and Agent assigned'],
285
- candidate_agent_ids=[selected['agent_id']],
323
+ candidate_agent_ids=[routing_selected['agent_id']],
286
324
  created_at=now,
287
325
  **self._decision_executor_fields(executor_decision),
288
326
  )
@@ -303,14 +341,30 @@ class StoreProjectAgentTaskRecruitment:
303
341
  recruitment['task_id'],
304
342
  'recruitment-approved',
305
343
  )
306
- await self.runtime.driver.execute_query(
344
+ records, _, _ = await self.runtime.driver.execute_query(
307
345
  '''
308
346
  MATCH (space:FuliSpace {
309
347
  id: $personal_space_id, kind: 'personal'
310
348
  })-[:HAS_PROJECT_AGENT_TASK]->
311
349
  (task:FuliProjectAgentTask {task_id: $task_id})
350
+ SET task._task_lifecycle_lock = true
351
+ REMOVE task._task_lifecycle_lock
352
+ WITH space, task
312
353
  MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
313
354
  (agent:FuliProjectAgent {agent_id: $agent_id})
355
+ MATCH (task)-[:HAS_PARTICIPANT]->
356
+ (lifecycle_agent:FuliProjectAgent)
357
+ WITH space, task, agent, lifecycle_agent
358
+ ORDER BY lifecycle_agent.agent_id
359
+ SET lifecycle_agent._task_lifecycle_lock = true
360
+ REMOVE lifecycle_agent._task_lifecycle_lock
361
+ WITH space, task, agent,
362
+ collect(lifecycle_agent) AS locked_agents
363
+ WHERE agent IN locked_agents
364
+ AND all(locked_agent IN locked_agents
365
+ WHERE locked_agent.status = 'active')
366
+ AND coalesce(task.revision, 0) = $expected_task_revision
367
+ AND task.status = 'awaiting_recruitment'
314
368
  OPTIONAL MATCH (task)-[old_route:ROUTED_BY]->()
315
369
  DELETE old_route
316
370
  SET task.status = $status,
@@ -335,6 +389,13 @@ class StoreProjectAgentTaskRecruitment:
335
389
  participant.assignment_summary = $assignment_summary,
336
390
  participant.joined_at = coalesce(participant.joined_at, $updated_at),
337
391
  participant.updated_at = $updated_at
392
+ WITH task, agent, locked_agents
393
+ MATCH (task)-[task_participant:HAS_PARTICIPANT]->
394
+ (participant_agent:FuliProjectAgent)
395
+ WHERE participant_agent IN locked_agents
396
+ SET task_participant.status = $status,
397
+ task_participant.updated_at = $updated_at
398
+ WITH DISTINCT task, agent
338
399
  MERGE (decision_node:FuliProjectAgentRoutingDecision {id: $decision_id})
339
400
  ON CREATE SET decision_node.decision_id = $decision_id,
340
401
  decision_node.task_id = $task_id,
@@ -351,10 +412,11 @@ class StoreProjectAgentTaskRecruitment:
351
412
  event.created_at = $updated_at
352
413
  MERGE (task)-[:HAS_TASK_EVENT]->(event)
353
414
  MERGE (event)-[:EVENT_AGENT]->(agent)
415
+ RETURN task
354
416
  ''',
355
417
  task_id=recruitment['task_id'],
356
418
  personal_space_id=recruitment['personal_space_id'],
357
- agent_id=selected['agent_id'],
419
+ agent_id=routing_selected['agent_id'],
358
420
  status=status,
359
421
  routing_outcome=outcome,
360
422
  routing_explanation=explanation,
@@ -377,13 +439,214 @@ class StoreProjectAgentTaskRecruitment:
377
439
  fallback_outcome=(executor_raw or {}).get('fallback_outcome'),
378
440
  fallback_reason=(executor_raw or {}).get('fallback_reason'),
379
441
  blocked_reason=(executor_raw or {}).get('blocked_reason'),
380
- assignment_summary=selected.get('responsibility'),
442
+ assignment_summary=routing_selected.get('responsibility'),
381
443
  decision_id=decision_id,
382
444
  decision_json=decision.model_dump_json(),
383
445
  event_id=event_id,
384
446
  event_summary=f'HR recruitment approved: {approval_reason}',
447
+ expected_task_revision=expected_task_revision,
385
448
  updated_at=now,
386
449
  )
450
+ if not records:
451
+ latest = await self._find_task_row(
452
+ recruitment['personal_space_id'],
453
+ recruitment['task_id'],
454
+ )
455
+ if latest and dict(latest['task']).get('status') != 'awaiting_recruitment':
456
+ return
457
+ if _attempt < 2:
458
+ return await self._activate_approved_recruitment(
459
+ actor,
460
+ recruitment,
461
+ selected,
462
+ approval_reason,
463
+ _attempt=_attempt + 1,
464
+ )
465
+ raise HTTPException(
466
+ status_code=409,
467
+ detail='recruitment task changed before activation',
468
+ )
469
+
470
+ async def _link_recruited_task_participant(self, recruitment, selected):
471
+ """Repair durable links without changing task lifecycle state."""
472
+
473
+ updated_at = now_utc()
474
+ records, _, _ = await self.runtime.driver.execute_query(
475
+ '''
476
+ MATCH (space:FuliSpace {
477
+ id: $personal_space_id, kind: 'personal'
478
+ })-[:HAS_PROJECT_AGENT_TASK]->
479
+ (task:FuliProjectAgentTask {task_id: $task_id})
480
+ SET task._task_lifecycle_lock = true
481
+ REMOVE task._task_lifecycle_lock
482
+ WITH space, task
483
+ MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
484
+ (recruitment:FuliProjectAgentRecruitment {
485
+ recruitment_id: $recruitment_id,
486
+ status: 'fulfilled'
487
+ })
488
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
489
+ (agent:FuliProjectAgent {agent_id: $agent_id})
490
+ SET agent._task_lifecycle_lock = true
491
+ REMOVE agent._task_lifecycle_lock
492
+ WITH task, recruitment, agent
493
+ WHERE agent.status = 'active'
494
+ MERGE (task)-[participant:HAS_PARTICIPANT]->(agent)
495
+ ON CREATE SET participant.role =
496
+ coalesce(recruitment.participant_role, 'lead'),
497
+ participant.status = task.status,
498
+ participant.assignment_summary = $assignment_summary,
499
+ participant.joined_at = $updated_at,
500
+ participant.updated_at = $updated_at
501
+ SET task.recruitment_id = coalesce(
502
+ task.recruitment_id, recruitment.recruitment_id),
503
+ task.hr_agent_id = coalesce(
504
+ task.hr_agent_id, recruitment.hr_agent_id)
505
+ MERGE (task)-[:TRIGGERED_RECRUITMENT]->(recruitment)
506
+ RETURN count(task) AS linked_count
507
+ ''',
508
+ personal_space_id=recruitment['personal_space_id'],
509
+ task_id=recruitment['task_id'],
510
+ recruitment_id=recruitment['recruitment_id'],
511
+ agent_id=selected['agent_id'],
512
+ assignment_summary=selected.get('responsibility'),
513
+ updated_at=updated_at,
514
+ )
515
+ return bool(records and records[0].get('linked_count') == 1)
516
+
517
+ async def _recruitment_routing_lead(
518
+ self,
519
+ recruitment,
520
+ selected,
521
+ raw_task,
522
+ task_recruitments,
523
+ ):
524
+ if (recruitment.get('participant_role') or 'lead') == 'lead':
525
+ return selected
526
+ lead_agent_id = raw_task.get('lead_agent_id')
527
+ if not lead_agent_id:
528
+ lead_recruitment = next(
529
+ (
530
+ item
531
+ for item in task_recruitments
532
+ if item.participant_role == 'lead'
533
+ ),
534
+ None,
535
+ )
536
+ if lead_recruitment:
537
+ lead_agent_id = (
538
+ lead_recruitment.recruited_agent_id
539
+ or lead_recruitment.proposed_agent_id
540
+ )
541
+ candidates = await self._assignment_candidates(
542
+ recruitment['personal_space_id'],
543
+ recruitment['personal_project_id'],
544
+ include_temporary=True,
545
+ )
546
+ lead = next(
547
+ (
548
+ item for item in candidates
549
+ if item['agent_id'] == lead_agent_id
550
+ ),
551
+ None,
552
+ )
553
+ if not lead:
554
+ raise HTTPException(
555
+ status_code=409,
556
+ detail='recruited task lead assignment is unavailable',
557
+ )
558
+ return lead
559
+
560
+ async def _recover_recruitment_lifecycle(
561
+ self,
562
+ actor,
563
+ request,
564
+ task,
565
+ recruitments,
566
+ ):
567
+ recovered_requested = False
568
+ if task.status == 'awaiting_recruitment':
569
+ expected_task_revision = int(getattr(task, 'revision', 0) or 0)
570
+ for record in recruitments:
571
+ if record.status != 'requested':
572
+ continue
573
+ recovery_guard = {
574
+ 'task_id': task.task_id,
575
+ 'expected_task_revision': expected_task_revision,
576
+ }
577
+ if await self._finalize_ready_recruitment(
578
+ request,
579
+ record.recruitment_id,
580
+ **recovery_guard,
581
+ ):
582
+ recovered_requested = True
583
+ expected_task_revision += 1
584
+ continue
585
+ claimed = await self._claim_requested_recruitment(
586
+ request,
587
+ record.recruitment_id,
588
+ **recovery_guard,
589
+ )
590
+ if not claimed:
591
+ continue
592
+ await self._provision_recruitment(actor, claimed)
593
+ recovered_requested = True
594
+ # The guarded claim and fulfillment each advance the Task CAS.
595
+ expected_task_revision += 2
596
+ if recovered_requested:
597
+ recruitments = await self._task_recruitments(
598
+ request.personal_space_id,
599
+ task.task_id,
600
+ )
601
+ fulfilled = [
602
+ item for item in recruitments if item.status == 'fulfilled'
603
+ ]
604
+ if not fulfilled:
605
+ return recovered_requested
606
+ candidates = await self._assignment_candidates(
607
+ request.personal_space_id,
608
+ request.personal_project_id,
609
+ include_temporary=True,
610
+ )
611
+ by_agent_id = {item['agent_id']: item for item in candidates}
612
+ recovered = []
613
+ for record in fulfilled:
614
+ raw = await self._get_recruitment_raw(
615
+ request.personal_space_id,
616
+ record.recruitment_id,
617
+ )
618
+ if not raw:
619
+ continue
620
+ agent_id = record.recruited_agent_id or record.proposed_agent_id
621
+ selected = by_agent_id.get(agent_id) or {
622
+ 'agent_id': agent_id,
623
+ 'responsibility': record.proposed_profile.responsibility,
624
+ 'profile': record.proposed_profile,
625
+ }
626
+ linked = await self._link_recruited_task_participant(raw, selected)
627
+ if linked is False:
628
+ continue
629
+ recovered.append((raw, selected))
630
+ if (
631
+ not recovered
632
+ or task.status != 'awaiting_recruitment'
633
+ or len(fulfilled) != len(recruitments)
634
+ ):
635
+ return bool(recovered) or recovered_requested
636
+ raw, selected = next(
637
+ (
638
+ item for item in recovered
639
+ if (item[0].get('participant_role') or 'lead') == 'lead'
640
+ ),
641
+ recovered[0],
642
+ )
643
+ await self._activate_approved_recruitment(
644
+ actor,
645
+ raw,
646
+ selected,
647
+ 'Recovered fulfilled recruitment activation during task replay.',
648
+ )
649
+ return True
387
650
 
388
651
  async def list_project_agent_recruitments(
389
652
  self,
@@ -431,7 +694,51 @@ class StoreProjectAgentTaskRecruitment:
431
694
  task_id,
432
695
  coordinator_agent_id,
433
696
  reason_code,
697
+ *,
698
+ participant_role='lead',
699
+ recruitment_slot=None,
434
700
  ):
701
+ if participant_role not in {'lead', 'collaborator'}:
702
+ raise ValueError('recruitment participant role is invalid')
703
+ slot = recruitment_slot or 'lead'
704
+ if not slot or len(slot) > 128:
705
+ raise ValueError('recruitment slot must contain 1 to 128 characters')
706
+
707
+ position_kind, profile = self._normalized_recruitment_profile(request)
708
+ self._require_agent_client_allowed(
709
+ profile,
710
+ request.source_application,
711
+ )
712
+ is_legacy_lead_slot = participant_role == 'lead' and slot == 'lead'
713
+ recruitment_id_parts = [task_id]
714
+ proposed_agent_id_parts = [task_id, position_kind]
715
+ if not is_legacy_lead_slot:
716
+ recruitment_id_parts.append(slot)
717
+ proposed_agent_id_parts.append(slot)
718
+ recruitment_id = stable_uuid(
719
+ self.settings.provider_id,
720
+ request.personal_space_id,
721
+ 'project-agent-recruitment',
722
+ *recruitment_id_parts,
723
+ )
724
+ proposed_agent_id = stable_uuid(
725
+ self.settings.provider_id,
726
+ request.personal_space_id,
727
+ 'recruited-project-agent',
728
+ *proposed_agent_id_parts,
729
+ )
730
+
731
+ existing = await self._get_recruitment(
732
+ request.personal_space_id,
733
+ recruitment_id,
734
+ )
735
+ if existing:
736
+ return await self._existing_recruitment_result(
737
+ actor,
738
+ request,
739
+ existing,
740
+ )
741
+
435
742
  hr_records, _, _ = await self.runtime.driver.execute_query(
436
743
  '''
437
744
  MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
@@ -445,45 +752,19 @@ class StoreProjectAgentTaskRecruitment:
445
752
  hr_agent_id = (
446
753
  dict(hr_records[0]['hr'])['agent_id'] if hr_records else None
447
754
  )
448
- policy = await self.get_project_agent_recruitment_policy(
755
+ coordination_policy = await self.get_project_agent_coordination_policy(
449
756
  actor,
450
757
  request.personal_space_id,
758
+ request.personal_project_id,
451
759
  )
452
- position_kind = (
453
- 'temporary'
454
- if request.staffing_intent == 'temporary'
455
- else 'durable'
456
- )
457
- profile = request.recruitment_profile or ProjectAgentProfile(
458
- name=f'{request.work_kind} Agent',
459
- responsibility=request.objective,
460
- agent_type=position_kind,
461
- work_kinds=[request.work_kind],
462
- capabilities=request.required_capabilities,
463
- status='active',
464
- )
465
- if profile.agent_type != position_kind:
466
- profile = profile.model_copy(update={'agent_type': position_kind})
467
- self._require_agent_client_allowed(
468
- profile,
469
- request.source_application,
470
- )
471
- recruitment_id = stable_uuid(
472
- self.settings.provider_id,
473
- request.personal_space_id,
474
- 'project-agent-recruitment',
475
- task_id,
476
- )
477
- proposed_agent_id = stable_uuid(
478
- self.settings.provider_id,
479
- request.personal_space_id,
480
- 'recruited-project-agent',
481
- task_id,
482
- position_kind,
760
+ confirmation_mode = (
761
+ 'require_confirmation'
762
+ if coordination_policy.ask_before_recruitment
763
+ else 'automatic'
483
764
  )
484
765
  if not hr_agent_id:
485
766
  status = 'no_hr'
486
- elif policy.confirmation_mode == 'require_confirmation':
767
+ elif confirmation_mode == 'require_confirmation':
487
768
  status = 'awaiting_confirmation'
488
769
  else:
489
770
  status = 'requested'
@@ -502,9 +783,11 @@ class StoreProjectAgentTaskRecruitment:
502
783
  'reason_code': reason_code,
503
784
  'reason': request.routing_reason,
504
785
  'status': status,
505
- 'confirmation_mode': policy.confirmation_mode,
786
+ 'confirmation_mode': confirmation_mode,
506
787
  'proposed_agent_id': proposed_agent_id,
507
788
  'proposed_profile_json': profile.model_dump_json(),
789
+ 'participant_role': participant_role,
790
+ 'recruitment_slot': slot,
508
791
  'occupation_emoji': profile.occupation_emoji,
509
792
  'trigger_source_application': request.source_application,
510
793
  'trigger_source_session_id': request.source_session_id,
@@ -513,8 +796,29 @@ class StoreProjectAgentTaskRecruitment:
513
796
  'updated_at': now,
514
797
  'test_source': profile.test_source,
515
798
  'cleanup_eligible': profile.cleanup_eligible,
799
+ 'provisioning_claim_id': (
800
+ str(uuid4()) if status == 'requested' else None
801
+ ),
802
+ 'provisioning_claimed_at': (
803
+ now if status == 'requested' else None
804
+ ),
516
805
  }
517
- await self._persist_recruitment(raw)
806
+ claimed = await self._persist_recruitment(raw)
807
+ if status == 'requested' and claimed is False:
808
+ existing = await self._get_recruitment(
809
+ request.personal_space_id,
810
+ recruitment_id,
811
+ )
812
+ if not existing:
813
+ raise HTTPException(
814
+ status_code=409,
815
+ detail='recruitment creation raced; retry task submission',
816
+ )
817
+ return await self._existing_recruitment_result(
818
+ actor,
819
+ request,
820
+ existing,
821
+ )
518
822
  record = self._recruitment(raw)
519
823
  if status != 'requested' or not hr_agent_id:
520
824
  return record, None
@@ -524,8 +828,225 @@ class StoreProjectAgentTaskRecruitment:
524
828
  recruitment_id,
525
829
  ), recruited
526
830
 
831
+ async def _existing_recruitment_result(self, actor, request, existing):
832
+ if existing.status == 'requested':
833
+ ready = await self._finalize_ready_recruitment(
834
+ request,
835
+ existing.recruitment_id,
836
+ )
837
+ if ready:
838
+ record = self._recruitment(ready)
839
+ return await self._fulfilled_recruitment_result(
840
+ request,
841
+ record,
842
+ )
843
+ raw = await self._claim_requested_recruitment(
844
+ request,
845
+ existing.recruitment_id,
846
+ )
847
+ if not raw:
848
+ raise HTTPException(
849
+ status_code=409,
850
+ detail=(
851
+ 'automatic recruitment is already provisioning; '
852
+ 'retry task submission'
853
+ ),
854
+ )
855
+ recruited = await self._provision_recruitment(actor, raw)
856
+ record = await self._get_recruitment(
857
+ request.personal_space_id,
858
+ existing.recruitment_id,
859
+ )
860
+ if not record:
861
+ raise HTTPException(status_code=404, detail='recruitment not found')
862
+ return record, recruited
863
+ if existing.status != 'fulfilled':
864
+ return existing, None
865
+ return await self._fulfilled_recruitment_result(request, existing)
866
+
867
+ async def _fulfilled_recruitment_result(self, request, existing):
868
+ assignment_rows = await self._assignment_candidates(
869
+ request.personal_space_id,
870
+ request.personal_project_id,
871
+ include_temporary=True,
872
+ )
873
+ recruited_agent_id = (
874
+ existing.recruited_agent_id or existing.proposed_agent_id
875
+ )
876
+ recruited = next(
877
+ (
878
+ item
879
+ for item in assignment_rows
880
+ if item['agent_id'] == recruited_agent_id
881
+ ),
882
+ None,
883
+ )
884
+ return existing, recruited
885
+
886
+ async def _finalize_ready_recruitment(
887
+ self,
888
+ request,
889
+ recruitment_id,
890
+ *,
891
+ task_id=None,
892
+ expected_task_revision=None,
893
+ ):
894
+ updated_at = now_utc()
895
+ records, _, _ = await self.runtime.driver.execute_query(
896
+ '''
897
+ MATCH (space:FuliSpace {
898
+ id: $personal_space_id, kind: 'personal'
899
+ })-[:HAS_PROJECT_AGENT_RECRUITMENT]->
900
+ (recruitment:FuliProjectAgentRecruitment {
901
+ recruitment_id: $recruitment_id,
902
+ status: 'requested'
903
+ })
904
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
905
+ (task:FuliProjectAgentTask {task_id: $task_id})
906
+ FOREACH (_ IN CASE WHEN task IS NULL THEN [] ELSE [1] END |
907
+ SET task._task_lifecycle_lock = true
908
+ )
909
+ FOREACH (_ IN CASE WHEN task IS NULL THEN [] ELSE [1] END |
910
+ REMOVE task._task_lifecycle_lock
911
+ )
912
+ WITH space, recruitment, task
913
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
914
+ (agent:FuliProjectAgent)
915
+ WHERE agent.agent_id = recruitment.proposed_agent_id
916
+ SET agent._task_lifecycle_lock = true
917
+ REMOVE agent._task_lifecycle_lock
918
+ WITH space, recruitment, agent, task
919
+ WHERE agent.status = 'active'
920
+ MATCH (space)-[:CONTAINS_PROJECT]->
921
+ (:FuliPersonalProject {project_id: $personal_project_id})-
922
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
923
+ (:FuliProjectAgentAssignment {status: 'active'})-
924
+ [:ASSIGNED_AGENT]->(agent)
925
+ WITH recruitment, agent, task
926
+ WHERE $task_id IS NULL
927
+ OR (
928
+ recruitment.task_id = $task_id
929
+ AND task.status = 'awaiting_recruitment'
930
+ AND coalesce(task.revision, 0) = $expected_task_revision
931
+ )
932
+ SET recruitment.status = 'fulfilled',
933
+ recruitment.recruited_agent_id = agent.agent_id,
934
+ recruitment.fulfilled_at = $updated_at,
935
+ recruitment.updated_at = $updated_at,
936
+ recruitment.revision = coalesce(recruitment.revision, 0) + 1,
937
+ agent.recruitment_id = recruitment.recruitment_id,
938
+ agent.recruited_at = coalesce(agent.recruited_at, $updated_at),
939
+ agent.recruitment_reason = recruitment.reason,
940
+ agent.recruitment_source_application =
941
+ recruitment.trigger_source_application,
942
+ agent.temporary_task_id = CASE recruitment.position_kind
943
+ WHEN 'temporary' THEN recruitment.task_id
944
+ ELSE agent.temporary_task_id END,
945
+ agent.test_source = recruitment.test_source,
946
+ agent.cleanup_eligible =
947
+ coalesce(recruitment.cleanup_eligible, false)
948
+ FOREACH (_ IN CASE WHEN $task_id IS NULL THEN [] ELSE [1] END |
949
+ SET task.recruitment_provisioning_claim_id = null,
950
+ task.recruitment_provisioning_claimed_at = null,
951
+ task.revision = coalesce(task.revision, 0) + 1,
952
+ task.updated_at = $updated_at
953
+ )
954
+ MERGE (recruitment)-[:CREATED_AGENT]->(agent)
955
+ RETURN recruitment
956
+ ''',
957
+ personal_space_id=request.personal_space_id,
958
+ personal_project_id=request.personal_project_id,
959
+ recruitment_id=recruitment_id,
960
+ task_id=task_id,
961
+ expected_task_revision=expected_task_revision,
962
+ updated_at=updated_at,
963
+ )
964
+ return dict(records[0]['recruitment']) if records else None
965
+
966
+ async def _claim_requested_recruitment(
967
+ self,
968
+ request,
969
+ recruitment_id,
970
+ *,
971
+ task_id=None,
972
+ expected_task_revision=None,
973
+ ):
974
+ claimed_at = now_utc()
975
+ claim_id = str(uuid4())
976
+ records, _, _ = await self.runtime.driver.execute_query(
977
+ '''
978
+ MATCH (space:FuliSpace {
979
+ id: $personal_space_id, kind: 'personal'
980
+ })-[:HAS_PROJECT_AGENT_RECRUITMENT]->
981
+ (recruitment:FuliProjectAgentRecruitment {
982
+ recruitment_id: $recruitment_id
983
+ })
984
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
985
+ (task:FuliProjectAgentTask {task_id: $task_id})
986
+ WITH recruitment, task
987
+ WHERE (
988
+ $task_id IS NULL
989
+ OR (
990
+ recruitment.task_id = $task_id
991
+ AND task.status = 'awaiting_recruitment'
992
+ AND coalesce(task.revision, 0) = $expected_task_revision
993
+ )
994
+ )
995
+ AND recruitment.status = 'requested'
996
+ AND (
997
+ recruitment.provisioning_claimed_at IS NULL
998
+ OR recruitment.provisioning_claimed_at <= $claim_expired_before
999
+ )
1000
+ SET recruitment.provisioning_claim_id = $provisioning_claim_id,
1001
+ recruitment.provisioning_claimed_at = $provisioning_claimed_at,
1002
+ recruitment.updated_at = $provisioning_claimed_at
1003
+ FOREACH (_ IN CASE WHEN $task_id IS NULL THEN [] ELSE [1] END |
1004
+ SET task.recruitment_provisioning_claim_id =
1005
+ $provisioning_claim_id,
1006
+ task.recruitment_provisioning_claimed_at =
1007
+ $provisioning_claimed_at,
1008
+ task.revision = coalesce(task.revision, 0) + 1,
1009
+ task.updated_at = $provisioning_claimed_at
1010
+ )
1011
+ RETURN recruitment
1012
+ ''',
1013
+ personal_space_id=request.personal_space_id,
1014
+ personal_project_id=request.personal_project_id,
1015
+ recruitment_id=recruitment_id,
1016
+ task_id=task_id,
1017
+ expected_task_revision=expected_task_revision,
1018
+ provisioning_claim_id=claim_id,
1019
+ provisioning_claimed_at=claimed_at,
1020
+ claim_expired_before=claimed_at - RECRUITMENT_CLAIM_TTL,
1021
+ )
1022
+ if not records:
1023
+ return None
1024
+ raw = dict(records[0]['recruitment'])
1025
+ if task_id:
1026
+ raw['_guarded_task_id'] = task_id
1027
+ return raw
1028
+
1029
+ @staticmethod
1030
+ def _normalized_recruitment_profile(request):
1031
+ position_kind = (
1032
+ 'temporary'
1033
+ if request.staffing_intent == 'temporary'
1034
+ else 'durable'
1035
+ )
1036
+ profile = request.recruitment_profile or ProjectAgentProfile(
1037
+ name=f'{request.work_kind} Agent',
1038
+ responsibility=request.objective,
1039
+ agent_type=position_kind,
1040
+ work_kinds=[request.work_kind],
1041
+ capabilities=request.required_capabilities,
1042
+ status='active',
1043
+ )
1044
+ if profile.agent_type != position_kind:
1045
+ profile = profile.model_copy(update={'agent_type': position_kind})
1046
+ return position_kind, profile
1047
+
527
1048
  async def _persist_recruitment(self, raw):
528
- await self.runtime.driver.execute_query(
1049
+ records, _, _ = await self.runtime.driver.execute_query(
529
1050
  '''
530
1051
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
531
1052
  MATCH (space)-[:CONTAINS_PROJECT]->
@@ -540,15 +1061,71 @@ class StoreProjectAgentTaskRecruitment:
540
1061
  FOREACH (_ IN CASE WHEN hr IS NULL THEN [] ELSE [1] END |
541
1062
  MERGE (recruitment)-[:RECRUITED_BY]->(hr)
542
1063
  )
1064
+ RETURN recruitment.provisioning_claim_id =
1065
+ $provisioning_claim_id AS provisioning_claimed
543
1066
  ''',
544
1067
  personal_space_id=raw['personal_space_id'],
545
1068
  personal_project_id=raw['personal_project_id'],
546
1069
  id=raw['id'],
547
1070
  properties=raw,
548
1071
  hr_agent_id=raw.get('hr_agent_id'),
1072
+ provisioning_claim_id=raw.get('provisioning_claim_id'),
1073
+ )
1074
+ return bool(
1075
+ records
1076
+ and dict(records[0]).get('provisioning_claimed')
549
1077
  )
550
1078
 
551
1079
  async def _provision_recruitment(self, actor, raw):
1080
+ # Identity, assignment and fulfillment either all commit or all roll back.
1081
+ # The audit/claim intentionally remains outside for recoverable retries.
1082
+ async with query_store_transaction(self) as scoped:
1083
+ return await scoped._provision_recruitment_transaction(actor, raw)
1084
+
1085
+ async def _provision_recruitment_transaction(self, actor, raw):
1086
+ guarded_task_id = raw.get('_guarded_task_id')
1087
+ if guarded_task_id:
1088
+ active, _, _ = await self.runtime.driver.execute_query(
1089
+ '''
1090
+ MATCH (:FuliSpace {
1091
+ id: $personal_space_id, kind: 'personal'
1092
+ })-[:HAS_PROJECT_AGENT_TASK]->
1093
+ (task:FuliProjectAgentTask {task_id: $task_id})
1094
+ SET task._recruitment_write_lock = true
1095
+ REMOVE task._recruitment_write_lock
1096
+ WITH task
1097
+ WHERE task.status = 'awaiting_recruitment'
1098
+ AND task.recruitment_provisioning_claim_id =
1099
+ $provisioning_claim_id
1100
+ RETURN task
1101
+ ''',
1102
+ personal_space_id=raw['personal_space_id'],
1103
+ task_id=guarded_task_id,
1104
+ provisioning_claim_id=raw.get('provisioning_claim_id'),
1105
+ routing_='r',
1106
+ )
1107
+ if not active:
1108
+ raise HTTPException(
1109
+ status_code=409,
1110
+ detail='recruitment task is no longer awaiting provisioning',
1111
+ )
1112
+ claimed, _, _ = await self.runtime.driver.execute_query(
1113
+ '''
1114
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
1115
+ -[:HAS_PROJECT_AGENT_RECRUITMENT]->
1116
+ (recruitment:FuliProjectAgentRecruitment {recruitment_id: $recruitment_id})
1117
+ SET recruitment._provision_write_lock = true
1118
+ REMOVE recruitment._provision_write_lock
1119
+ WITH recruitment
1120
+ WHERE recruitment.status = 'requested'
1121
+ AND recruitment.provisioning_claim_id = $provisioning_claim_id
1122
+ RETURN recruitment
1123
+ ''',
1124
+ **{key: raw.get(key) for key in (
1125
+ 'personal_space_id', 'recruitment_id', 'provisioning_claim_id')},
1126
+ )
1127
+ if not claimed:
1128
+ raise HTTPException(status_code=409, detail='recruitment provisioning claim is stale')
552
1129
  profile = ProjectAgentProfile.model_validate_json(
553
1130
  raw['proposed_profile_json']
554
1131
  )
@@ -585,7 +1162,7 @@ class StoreProjectAgentTaskRecruitment:
585
1162
  ),
586
1163
  )
587
1164
  updated_at = now_utc()
588
- await self.runtime.driver.execute_query(
1165
+ records, _, _ = await self.runtime.driver.execute_query(
589
1166
  '''
590
1167
  MATCH (space:FuliSpace {
591
1168
  id: $personal_space_id, kind: 'personal'
@@ -595,11 +1172,24 @@ class StoreProjectAgentTaskRecruitment:
595
1172
  })
596
1173
  MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
597
1174
  (agent:FuliProjectAgent {agent_id: $agent_id})
1175
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
1176
+ (task:FuliProjectAgentTask {task_id: $task_id})
1177
+ WITH recruitment, agent, task
1178
+ WHERE recruitment.status = 'requested'
1179
+ AND recruitment.provisioning_claim_id = $provisioning_claim_id
1180
+ AND (
1181
+ NOT $task_guarded
1182
+ OR (
1183
+ task.status = 'awaiting_recruitment'
1184
+ AND task.recruitment_provisioning_claim_id =
1185
+ $provisioning_claim_id
1186
+ )
1187
+ )
598
1188
  SET recruitment.status = 'fulfilled',
599
1189
  recruitment.recruited_agent_id = $agent_id,
600
1190
  recruitment.fulfilled_at = $updated_at,
601
1191
  recruitment.updated_at = $updated_at,
602
- recruitment.revision = recruitment.revision + 1,
1192
+ recruitment.revision = coalesce(recruitment.revision, 0) + 1,
603
1193
  agent.recruitment_id = $recruitment_id,
604
1194
  agent.recruited_at = $updated_at,
605
1195
  agent.recruitment_reason = recruitment.reason,
@@ -610,13 +1200,33 @@ class StoreProjectAgentTaskRecruitment:
610
1200
  agent.test_source = recruitment.test_source,
611
1201
  agent.cleanup_eligible =
612
1202
  coalesce(recruitment.cleanup_eligible, false)
1203
+ FOREACH (_ IN CASE WHEN $task_guarded THEN [1] ELSE [] END |
1204
+ SET task.recruitment_provisioning_claim_id = null,
1205
+ task.recruitment_provisioning_claimed_at = null,
1206
+ task.revision = coalesce(task.revision, 0) + 1,
1207
+ task.updated_at = $updated_at
1208
+ )
613
1209
  MERGE (recruitment)-[:CREATED_AGENT]->(agent)
1210
+ RETURN recruitment
614
1211
  ''',
615
1212
  recruitment_id=raw['recruitment_id'],
616
1213
  personal_space_id=raw['personal_space_id'],
617
1214
  agent_id=agent_id,
1215
+ task_id=guarded_task_id,
1216
+ task_guarded=bool(guarded_task_id),
1217
+ provisioning_claim_id=raw.get('provisioning_claim_id'),
618
1218
  updated_at=updated_at,
619
1219
  )
1220
+ if not records:
1221
+ existing = await self._get_recruitment(
1222
+ raw['personal_space_id'],
1223
+ raw['recruitment_id'],
1224
+ )
1225
+ if not existing or existing.status != 'fulfilled':
1226
+ raise HTTPException(
1227
+ status_code=409,
1228
+ detail='recruitment provisioning claim is no longer active',
1229
+ )
620
1230
  assignment_rows = await self._assignment_candidates(
621
1231
  raw['personal_space_id'],
622
1232
  raw['personal_project_id'],
@@ -626,6 +1236,13 @@ class StoreProjectAgentTaskRecruitment:
626
1236
  return selected
627
1237
 
628
1238
  async def _get_recruitment(self, personal_space_id, recruitment_id):
1239
+ raw = await self._get_recruitment_raw(
1240
+ personal_space_id,
1241
+ recruitment_id,
1242
+ )
1243
+ return self._recruitment(raw) if raw else None
1244
+
1245
+ async def _get_recruitment_raw(self, personal_space_id, recruitment_id):
629
1246
  records, _, _ = await self.runtime.driver.execute_query(
630
1247
  '''
631
1248
  MATCH (recruitment:FuliProjectAgentRecruitment {
@@ -638,62 +1255,37 @@ class StoreProjectAgentTaskRecruitment:
638
1255
  recruitment_id=recruitment_id,
639
1256
  routing_='r',
640
1257
  )
641
- return (
642
- self._recruitment(dict(records[0]['recruitment']))
643
- if records else None
644
- )
1258
+ return dict(records[0]['recruitment']) if records else None
645
1259
 
646
1260
  async def _task_recruitment(self, personal_space_id, task_id):
1261
+ recruitments = await self._task_recruitments(
1262
+ personal_space_id,
1263
+ task_id,
1264
+ )
1265
+ return recruitments[0] if recruitments else None
1266
+
1267
+ async def _task_recruitments(self, personal_space_id, task_id):
647
1268
  records, _, _ = await self.runtime.driver.execute_query(
648
1269
  '''
649
1270
  MATCH (recruitment:FuliProjectAgentRecruitment {
650
1271
  personal_space_id: $personal_space_id,
651
1272
  task_id: $task_id
652
1273
  })
653
- RETURN recruitment ORDER BY recruitment.created_at DESC LIMIT 1
1274
+ RETURN recruitment
1275
+ ORDER BY CASE
1276
+ WHEN coalesce(recruitment.participant_role, 'lead') = 'lead'
1277
+ THEN 0 ELSE 1 END,
1278
+ recruitment.created_at,
1279
+ recruitment.recruitment_id
654
1280
  ''',
655
1281
  personal_space_id=personal_space_id,
656
1282
  task_id=task_id,
657
1283
  routing_='r',
658
1284
  )
659
- return self._recruitment(dict(records[0]['recruitment'])) if records else None
1285
+ return [
1286
+ self._recruitment(dict(row['recruitment']))
1287
+ for row in records
1288
+ ]
660
1289
 
661
1290
  def _recruitment(self, raw):
662
- proposed_profile = ProjectAgentProfile.model_validate_json(
663
- raw['proposed_profile_json']
664
- )
665
- occupation_emoji = raw.get('occupation_emoji') or raw.get(
666
- 'occupationEmoji'
667
- )
668
- if not proposed_profile.occupation_emoji and occupation_emoji:
669
- proposed_profile = proposed_profile.model_copy(
670
- update={'occupation_emoji': occupation_emoji}
671
- )
672
- fields = {
673
- 'recruitment_id': raw.get('recruitment_id') or raw['id'],
674
- 'personal_space_id': raw['personal_space_id'],
675
- 'personal_project_id': raw['personal_project_id'],
676
- 'task_id': raw['task_id'],
677
- 'coordinator_agent_id': raw['coordinator_agent_id'],
678
- 'hr_agent_id': raw.get('hr_agent_id'),
679
- 'position_kind': raw['position_kind'],
680
- 'work_kind': raw['work_kind'],
681
- 'required_capabilities': list(raw.get('required_capabilities') or []),
682
- 'reason_code': raw['reason_code'],
683
- 'reason': raw['reason'],
684
- 'status': raw['status'],
685
- 'confirmation_mode': raw['confirmation_mode'],
686
- 'proposed_agent_id': raw['proposed_agent_id'],
687
- 'proposed_profile': proposed_profile,
688
- 'trigger_source_application': raw.get('trigger_source_application'),
689
- 'trigger_source_session_id': raw.get('trigger_source_session_id'),
690
- 'revision': int(raw.get('revision') or 0),
691
- 'recruited_agent_id': raw.get('recruited_agent_id'),
692
- 'created_at': native_datetime(raw['created_at']),
693
- 'updated_at': native_datetime(raw['updated_at']),
694
- 'fulfilled_at': native_datetime(raw.get('fulfilled_at')),
695
- }
696
- for name in ('test_source', 'cleanup_eligible'):
697
- if name in ProjectAgentRecruitmentRecord.model_fields:
698
- fields[name] = raw.get(name)
699
- return ProjectAgentRecruitmentRecord(**fields)
1291
+ return project_agent_recruitment_record(raw)