multi-agent-platform 0.1.0__py3-none-any.whl
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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from map_types.enums import AcceptanceType
|
|
7
|
+
|
|
8
|
+
from server.domain.schemas import AcceptanceStatusRead
|
|
9
|
+
from server.services.errors import StateTransitionError
|
|
10
|
+
from server.services.evidence_service import metadata_has_completion_evidence
|
|
11
|
+
|
|
12
|
+
_ACCEPTANCE_MARKER_RE = re.compile(
|
|
13
|
+
r"\[acceptance_type:\s*(?P<kind>[a-zA-Z_]+)\]",
|
|
14
|
+
re.IGNORECASE,
|
|
15
|
+
)
|
|
16
|
+
_ACCEPTANCE_ITEM_RE = re.compile(
|
|
17
|
+
r"^\s*(?:[-*]|\d+[.)])\s*"
|
|
18
|
+
r"\[acceptance_type:\s*(?P<kind>[a-zA-Z_]+)\]\s*"
|
|
19
|
+
r"(?P<description>.+?)\s*$",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_acceptance_status(
|
|
25
|
+
content_md: str, *, completion_metadata: dict | None = None
|
|
26
|
+
) -> list[AcceptanceStatusRead]:
|
|
27
|
+
statuses: list[AcceptanceStatusRead] = []
|
|
28
|
+
has_generic_evidence = metadata_has_completion_evidence(completion_metadata)
|
|
29
|
+
for line in content_md.splitlines():
|
|
30
|
+
match = _ACCEPTANCE_ITEM_RE.match(line)
|
|
31
|
+
if match is None:
|
|
32
|
+
continue
|
|
33
|
+
raw_kind = match.group("kind").lower()
|
|
34
|
+
try:
|
|
35
|
+
acceptance_type = AcceptanceType(raw_kind)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
allowed = ", ".join(item.value for item in AcceptanceType)
|
|
38
|
+
raise StateTransitionError(
|
|
39
|
+
f"Unknown acceptance_type {raw_kind!r}; allowed values: {allowed}"
|
|
40
|
+
) from exc
|
|
41
|
+
description = " ".join(match.group("description").split())
|
|
42
|
+
acceptance_id = _stable_acceptance_id(acceptance_type.value, description)
|
|
43
|
+
statuses.append(
|
|
44
|
+
AcceptanceStatusRead(
|
|
45
|
+
id=acceptance_id,
|
|
46
|
+
description=description,
|
|
47
|
+
acceptance_type=acceptance_type,
|
|
48
|
+
evidence_provided=has_generic_evidence
|
|
49
|
+
or _metadata_has_acceptance_evidence(
|
|
50
|
+
completion_metadata,
|
|
51
|
+
acceptance_id=acceptance_id,
|
|
52
|
+
acceptance_type=acceptance_type.value,
|
|
53
|
+
description=description,
|
|
54
|
+
),
|
|
55
|
+
reviewer_verdict=None,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return statuses
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stable_acceptance_id(acceptance_type: str, description: str) -> str:
|
|
62
|
+
digest = hashlib.sha1(f"{acceptance_type}:{description}".encode()).hexdigest()
|
|
63
|
+
return f"acc-{digest[:12]}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _metadata_has_acceptance_evidence(
|
|
67
|
+
metadata: object, *, acceptance_id: str, acceptance_type: str, description: str
|
|
68
|
+
) -> bool:
|
|
69
|
+
if not isinstance(metadata, dict):
|
|
70
|
+
return False
|
|
71
|
+
acceptance = metadata.get("acceptance")
|
|
72
|
+
if isinstance(acceptance, dict):
|
|
73
|
+
for key in (acceptance_id, acceptance_type, description):
|
|
74
|
+
value = acceptance.get(key)
|
|
75
|
+
if value not in (None, "", [], {}, False):
|
|
76
|
+
return True
|
|
77
|
+
if isinstance(acceptance, list):
|
|
78
|
+
for item in acceptance:
|
|
79
|
+
if not isinstance(item, dict):
|
|
80
|
+
continue
|
|
81
|
+
evidence = item.get("evidence") or item.get("evidence_provided")
|
|
82
|
+
if evidence in (None, "", [], {}, False):
|
|
83
|
+
continue
|
|
84
|
+
if (
|
|
85
|
+
item.get("id") == acceptance_id
|
|
86
|
+
or item.get("acceptance_type") == acceptance_type
|
|
87
|
+
or item.get("description") == description
|
|
88
|
+
):
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Migrate or deliver open action items tied to closed/archived topics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import or_, select
|
|
9
|
+
from sqlalchemy.orm import Session, joinedload
|
|
10
|
+
|
|
11
|
+
from server.domain.models import AuditLog, Topic, TopicActionItem, TopicActionItemStatus, TopicStatus
|
|
12
|
+
from server.services import audit_service, topic_service
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MigrationResult:
|
|
17
|
+
action_item_id: uuid.UUID
|
|
18
|
+
strategy: str
|
|
19
|
+
from_topic_id: uuid.UUID
|
|
20
|
+
to_topic_id: uuid.UUID | None
|
|
21
|
+
dry_run: bool
|
|
22
|
+
skipped: bool
|
|
23
|
+
reason: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _idempotency_key(item_id: uuid.UUID) -> str:
|
|
27
|
+
return f"action_item_migrate:{item_id}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def migration_already_applied(db: Session, item_id: uuid.UUID) -> bool:
|
|
31
|
+
key = _idempotency_key(item_id)
|
|
32
|
+
rows = db.scalars(
|
|
33
|
+
select(AuditLog).where(
|
|
34
|
+
AuditLog.action == "action_item.migrated",
|
|
35
|
+
AuditLog.target_id == item_id,
|
|
36
|
+
)
|
|
37
|
+
).all()
|
|
38
|
+
return any((row.payload_json or {}).get("idempotency_key") == key for row in rows)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def list_stale_open_action_items(db: Session) -> list[TopicActionItem]:
|
|
42
|
+
"""Open items whose source topic is closed or archived (not soft-deleted)."""
|
|
43
|
+
return list(
|
|
44
|
+
db.scalars(
|
|
45
|
+
select(TopicActionItem)
|
|
46
|
+
.join(Topic, TopicActionItem.topic_id == Topic.id)
|
|
47
|
+
.options(joinedload(TopicActionItem.topic))
|
|
48
|
+
.where(
|
|
49
|
+
TopicActionItem.status == TopicActionItemStatus.open,
|
|
50
|
+
Topic.deleted_at.is_(None),
|
|
51
|
+
or_(
|
|
52
|
+
Topic.status == TopicStatus.closed,
|
|
53
|
+
Topic.archived_at.isnot(None),
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
.order_by(TopicActionItem.updated_at.desc())
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def migrate_action_item(
|
|
62
|
+
db: Session,
|
|
63
|
+
item: TopicActionItem,
|
|
64
|
+
*,
|
|
65
|
+
migrate_to_topic_id: uuid.UUID | None = None,
|
|
66
|
+
agent_id: uuid.UUID | None = None,
|
|
67
|
+
dry_run: bool = False,
|
|
68
|
+
) -> MigrationResult:
|
|
69
|
+
from_topic_id = item.topic_id
|
|
70
|
+
if migration_already_applied(db, item.id):
|
|
71
|
+
return MigrationResult(
|
|
72
|
+
action_item_id=item.id,
|
|
73
|
+
strategy="skip",
|
|
74
|
+
from_topic_id=from_topic_id,
|
|
75
|
+
to_topic_id=None,
|
|
76
|
+
dry_run=dry_run,
|
|
77
|
+
skipped=True,
|
|
78
|
+
reason="already_migrated",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if migrate_to_topic_id is not None:
|
|
82
|
+
target = db.get(Topic, migrate_to_topic_id)
|
|
83
|
+
if target is None or target.deleted_at is not None:
|
|
84
|
+
raise ValueError(f"Target topic {migrate_to_topic_id} not found")
|
|
85
|
+
strategy = "migrate_to"
|
|
86
|
+
to_topic_id = migrate_to_topic_id
|
|
87
|
+
if not dry_run:
|
|
88
|
+
item.topic_id = migrate_to_topic_id
|
|
89
|
+
audit_service.log_no_commit(
|
|
90
|
+
db,
|
|
91
|
+
action="action_item.migrated",
|
|
92
|
+
target_type="topic_action_item",
|
|
93
|
+
target_id=item.id,
|
|
94
|
+
agent_id=agent_id,
|
|
95
|
+
project_id=item.project_id,
|
|
96
|
+
summary=f"迁移行动项「{item.title}」到新话题",
|
|
97
|
+
payload={
|
|
98
|
+
"idempotency_key": _idempotency_key(item.id),
|
|
99
|
+
"from_topic_id": str(from_topic_id),
|
|
100
|
+
"to_topic_id": str(to_topic_id),
|
|
101
|
+
"strategy": strategy,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
db.commit()
|
|
105
|
+
return MigrationResult(
|
|
106
|
+
action_item_id=item.id,
|
|
107
|
+
strategy=strategy,
|
|
108
|
+
from_topic_id=from_topic_id,
|
|
109
|
+
to_topic_id=to_topic_id,
|
|
110
|
+
dry_run=dry_run,
|
|
111
|
+
skipped=False,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
strategy = "cascade_backlog"
|
|
115
|
+
if not dry_run:
|
|
116
|
+
suggested_id, _ = topic_service._suggest_linked_experiment(db, item)
|
|
117
|
+
if suggested_id is not None:
|
|
118
|
+
item.linked_experiment_id = suggested_id
|
|
119
|
+
topic_service.deliver_action_item_no_commit(
|
|
120
|
+
db,
|
|
121
|
+
item,
|
|
122
|
+
triggered_by="migration:cascade_backlog",
|
|
123
|
+
)
|
|
124
|
+
audit_service.log_no_commit(
|
|
125
|
+
db,
|
|
126
|
+
action="action_item.migrated",
|
|
127
|
+
target_type="topic_action_item",
|
|
128
|
+
target_id=item.id,
|
|
129
|
+
agent_id=agent_id,
|
|
130
|
+
project_id=item.project_id,
|
|
131
|
+
summary=f"迁移关闭行动项「{item.title}」(cascade backlog)",
|
|
132
|
+
payload={
|
|
133
|
+
"idempotency_key": _idempotency_key(item.id),
|
|
134
|
+
"from_topic_id": str(from_topic_id),
|
|
135
|
+
"to_topic_id": None,
|
|
136
|
+
"strategy": strategy,
|
|
137
|
+
"linked_experiment_id": str(item.linked_experiment_id)
|
|
138
|
+
if item.linked_experiment_id
|
|
139
|
+
else None,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
db.commit()
|
|
143
|
+
return MigrationResult(
|
|
144
|
+
action_item_id=item.id,
|
|
145
|
+
strategy=strategy,
|
|
146
|
+
from_topic_id=from_topic_id,
|
|
147
|
+
to_topic_id=None,
|
|
148
|
+
dry_run=dry_run,
|
|
149
|
+
skipped=False,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def migrate_stale_open_action_items(
|
|
154
|
+
db: Session,
|
|
155
|
+
*,
|
|
156
|
+
migrate_to_topic_id: uuid.UUID | None = None,
|
|
157
|
+
agent_id: uuid.UUID | None = None,
|
|
158
|
+
dry_run: bool = False,
|
|
159
|
+
) -> list[MigrationResult]:
|
|
160
|
+
results: list[MigrationResult] = []
|
|
161
|
+
for item in list_stale_open_action_items(db):
|
|
162
|
+
try:
|
|
163
|
+
results.append(
|
|
164
|
+
migrate_action_item(
|
|
165
|
+
db,
|
|
166
|
+
item,
|
|
167
|
+
migrate_to_topic_id=migrate_to_topic_id,
|
|
168
|
+
agent_id=agent_id,
|
|
169
|
+
dry_run=dry_run,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
if not dry_run:
|
|
174
|
+
db.rollback()
|
|
175
|
+
audit_service.log(
|
|
176
|
+
db,
|
|
177
|
+
action="action_item.migration_failed",
|
|
178
|
+
target_type="topic_action_item",
|
|
179
|
+
target_id=item.id,
|
|
180
|
+
agent_id=agent_id,
|
|
181
|
+
project_id=item.project_id,
|
|
182
|
+
summary=f"迁移行动项失败「{item.title}」",
|
|
183
|
+
payload={
|
|
184
|
+
"action_item_id": str(item.id),
|
|
185
|
+
"from_topic_id": str(item.topic_id),
|
|
186
|
+
"error": str(exc),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
raise
|
|
190
|
+
return results
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Action-item wake / stale lifecycle helpers (experiment B, plan §3).
|
|
2
|
+
|
|
3
|
+
This module owns the *transaction-internal* mutations that the runtime-waker
|
|
4
|
+
needs to advance the three-stage escalation timeline:
|
|
5
|
+
|
|
6
|
+
- ``mark_wake_sent_no_commit``: bump ``wake_count``, stamp ``last_woken_at``,
|
|
7
|
+
emit the ``action_item.wake_sent`` audit row — all in the caller's tx.
|
|
8
|
+
- ``mark_stale_no_commit``: stamp ``stale_at``, emit the ``action_item.stale``
|
|
9
|
+
audit row — all in the caller's tx.
|
|
10
|
+
|
|
11
|
+
Public ``mark_wake_sent`` / ``mark_stale`` wrappers add lookup + commit for
|
|
12
|
+
direct callers (API, dogfood scripts). The ``_no_commit`` variants are what
|
|
13
|
+
the waker should call from its own session.
|
|
14
|
+
|
|
15
|
+
Why a separate service module instead of inlining into ``topic_service.py``:
|
|
16
|
+
the waker has its own import surface (no FastAPI deps, no project_status
|
|
17
|
+
recursion) and the topic module is already 700+ lines. Splitting keeps the
|
|
18
|
+
mutation contract co-located with its audit helpers and the I3 acceptance
|
|
19
|
+
tests.
|
|
20
|
+
|
|
21
|
+
Related plan sections:
|
|
22
|
+
- §1 audit event ``action_item.stale`` payload schema
|
|
23
|
+
- §2 ``first_open_at`` / ``last_woken_at`` / ``stale_at`` lifecycle
|
|
24
|
+
- §3 waker scan / stale trigger
|
|
25
|
+
- §4 clock-skew policy: every timestamp is server time, never client time
|
|
26
|
+
- §8 B-1..B-13 acceptance — these helpers back B-2..B-7, B-11
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import uuid
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
|
|
34
|
+
from sqlalchemy.orm import Session
|
|
35
|
+
|
|
36
|
+
from server.domain.models import TopicActionItem
|
|
37
|
+
from server.services import audit_service
|
|
38
|
+
|
|
39
|
+
# Wake-history window from plan §3. Mirrored here so service-level callers
|
|
40
|
+
# (admin scripts, dogfood) can introspect the same numbers the waker uses
|
|
41
|
+
# without re-parsing the plan markdown.
|
|
42
|
+
WAKE_STAGE_THRESHOLDS: tuple[tuple[int, int], ...] = (
|
|
43
|
+
# (wake_count_after_increment, minimum_hours_since_first_open)
|
|
44
|
+
(1, 24), # T+24h first wake
|
|
45
|
+
(2, 72), # T+72h second wake
|
|
46
|
+
)
|
|
47
|
+
WAKE_REPEAT_INTERVAL_DAYS = 7 # after the 72h wake, fire every 7d
|
|
48
|
+
WAKE_MAX_COUNT_BEFORE_STALE = 4 # 4th unanswered wake -> stale
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _utcnow() -> datetime:
|
|
52
|
+
"""Return the waker-canonical ``datetime.now(UTC)``.
|
|
53
|
+
|
|
54
|
+
Centralised so tests can monkey-patch the service module rather than
|
|
55
|
+
chasing ``datetime.now`` through every call site (plan §4 clock-skew
|
|
56
|
+
requirement: only server time, never client time).
|
|
57
|
+
"""
|
|
58
|
+
return datetime.now(UTC)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ensure_first_open_at(item: TopicActionItem, now: datetime) -> None:
|
|
62
|
+
"""Stamp ``first_open_at`` on the first transition into ``open``.
|
|
63
|
+
|
|
64
|
+
Per plan §2: ``first_open_at`` is written when an item enters the ``open``
|
|
65
|
+
status (creation, or reopen from ``done``/``cancelled``). Re-triggering
|
|
66
|
+
a wake on an already-open item must NOT reset this — the T+24h / T+72h
|
|
67
|
+
window is anchored to the moment the item first became open.
|
|
68
|
+
|
|
69
|
+
The backfill for already-open rows is handled by alembic migration
|
|
70
|
+
``027_topic_action_item_escalation_fields.py`` — this helper only sets
|
|
71
|
+
the timestamp when it's still null.
|
|
72
|
+
"""
|
|
73
|
+
if item.status.value == "open" and item.first_open_at is None:
|
|
74
|
+
item.first_open_at = now
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def mark_wake_sent_no_commit(
|
|
78
|
+
db: Session,
|
|
79
|
+
*,
|
|
80
|
+
item: TopicActionItem,
|
|
81
|
+
now: datetime | None = None,
|
|
82
|
+
) -> int:
|
|
83
|
+
"""Bump ``wake_count`` + stamp ``last_woken_at`` + audit row, no commit.
|
|
84
|
+
|
|
85
|
+
Caller is responsible for the final ``db.commit()``. Returns the new
|
|
86
|
+
``wake_count`` so the waker can decide whether to advance to ``stale``
|
|
87
|
+
on the next cycle (returns 1..4; values beyond ``WAKE_MAX_COUNT_BEFORE_STALE``
|
|
88
|
+
mean the waker is calling this too aggressively and the caller should
|
|
89
|
+
pivot to ``mark_stale_no_commit`` instead).
|
|
90
|
+
|
|
91
|
+
Invariants (plan §3 + §4):
|
|
92
|
+
- Only fires when ``status == open`` and ``owner_agent_id`` is set;
|
|
93
|
+
``wake_count`` is meaningless on closed items.
|
|
94
|
+
- ``last_woken_at`` and the audit row's ``last_woken_at`` field both
|
|
95
|
+
use the same ``now`` value (caller-supplied or server-time default)
|
|
96
|
+
so grep-side reconstruction matches the DB.
|
|
97
|
+
- First call also writes ``first_open_at`` if it's still null — this
|
|
98
|
+
covers the I1 contract that backfill only handles pre-existing rows.
|
|
99
|
+
"""
|
|
100
|
+
if item.status.value != "open":
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"mark_wake_sent requires status=open, got {item.status.value}"
|
|
103
|
+
)
|
|
104
|
+
if item.owner_agent_id is None:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"mark_wake_sent requires owner_agent_id — "
|
|
107
|
+
"waker only wakes the assignee"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
when = now or _utcnow()
|
|
111
|
+
_ensure_first_open_at(item, when)
|
|
112
|
+
item.wake_count = (item.wake_count or 0) + 1
|
|
113
|
+
item.last_woken_at = when
|
|
114
|
+
|
|
115
|
+
audit_service.log_action_item_wake_sent(db, item=item, now=when)
|
|
116
|
+
return item.wake_count
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def mark_stale_no_commit(
|
|
120
|
+
db: Session,
|
|
121
|
+
*,
|
|
122
|
+
item: TopicActionItem,
|
|
123
|
+
now: datetime | None = None,
|
|
124
|
+
admin_notified: bool = True,
|
|
125
|
+
creator_audit_only: bool = True,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Stamp ``stale_at`` + write the diagnostic audit row, no commit.
|
|
128
|
+
|
|
129
|
+
Idempotent: if ``stale_at`` is already set we skip re-writing (the audit
|
|
130
|
+
row is a one-shot diagnostic — repeat wakes after stale must not spam
|
|
131
|
+
audit). Caller still owns the commit boundary.
|
|
132
|
+
|
|
133
|
+
Validation is delegated to ``audit_service._log_action_item_stale_no_commit``
|
|
134
|
+
which rejects unassigned items, items with no prior wake, and
|
|
135
|
+
``stale_after_attempt < 1``. We pass ``stale_after_attempt=item.wake_count``
|
|
136
|
+
because the plan defines stale as "the 4th unanswered wake" — the count
|
|
137
|
+
after the increment.
|
|
138
|
+
"""
|
|
139
|
+
if item.status.value != "open":
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"mark_stale requires status=open, got {item.status.value}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if item.stale_at is not None:
|
|
145
|
+
return # already stale — plan §5: do not re-write diagnostic
|
|
146
|
+
|
|
147
|
+
when = now or _utcnow()
|
|
148
|
+
item.stale_at = when
|
|
149
|
+
|
|
150
|
+
audit_service._log_action_item_stale_no_commit(
|
|
151
|
+
db,
|
|
152
|
+
item=item,
|
|
153
|
+
stale_after_attempt=item.wake_count,
|
|
154
|
+
admin_notified=admin_notified,
|
|
155
|
+
creator_audit_only=creator_audit_only,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def mark_wake_sent(
|
|
160
|
+
db: Session,
|
|
161
|
+
action_item_id: uuid.UUID,
|
|
162
|
+
*,
|
|
163
|
+
now: datetime | None = None,
|
|
164
|
+
) -> TopicActionItem:
|
|
165
|
+
"""Public wrapper: lookup + ``mark_wake_sent_no_commit`` + commit.
|
|
166
|
+
|
|
167
|
+
Kept for API symmetry with ``complete_action_item`` / ``cancel_action_item``.
|
|
168
|
+
The waker itself should call the ``_no_commit`` variant directly.
|
|
169
|
+
"""
|
|
170
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
171
|
+
if item is None:
|
|
172
|
+
raise LookupError(f"action item {action_item_id} not found")
|
|
173
|
+
mark_wake_sent_no_commit(db, item=item, now=now)
|
|
174
|
+
db.commit()
|
|
175
|
+
db.refresh(item)
|
|
176
|
+
return item
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def mark_stale(
|
|
180
|
+
db: Session,
|
|
181
|
+
action_item_id: uuid.UUID,
|
|
182
|
+
*,
|
|
183
|
+
now: datetime | None = None,
|
|
184
|
+
admin_notified: bool = True,
|
|
185
|
+
creator_audit_only: bool = True,
|
|
186
|
+
) -> TopicActionItem:
|
|
187
|
+
"""Public wrapper: lookup + ``mark_stale_no_commit`` + commit."""
|
|
188
|
+
item = db.get(TopicActionItem, action_item_id)
|
|
189
|
+
if item is None:
|
|
190
|
+
raise LookupError(f"action item {action_item_id} not found")
|
|
191
|
+
mark_stale_no_commit(
|
|
192
|
+
db,
|
|
193
|
+
item=item,
|
|
194
|
+
now=now,
|
|
195
|
+
admin_notified=admin_notified,
|
|
196
|
+
creator_audit_only=creator_audit_only,
|
|
197
|
+
)
|
|
198
|
+
db.commit()
|
|
199
|
+
db.refresh(item)
|
|
200
|
+
return item
|