rename team lifecycle events to Joined/Left
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 28s

TeamMemberBorn -> TeamMemberJoined, TeamMemberDied -> TeamMemberLeft
(kinds member-joined/member-left). Less morbid and a better fit for
teammates coming and going. No consumers yet, so a plain rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 01:19:59 +02:00
co-authored by Claude Opus 4.7
parent fc55f58e6b
commit 4a59ea4137
5 changed files with 54 additions and 52 deletions
@@ -2150,3 +2150,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:41:28', '2026-05-22 22:41:28', '2026-05-22 22:41:28', NULL, 'eda6f75e88401ee5b445e7edfc312f82', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:43:45', '2026-05-22 22:43:45', '2026-05-22 22:43:45', NULL, 'da17b2710e17b1821882d47724790cbb', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:50:41', '2026-05-22 22:50:41', '2026-05-22 22:50:41', NULL, 'ad063f0c3c65e5b35d3dcea036412ad9', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'status', 'in_progress', 'done', NULL, '2026-05-22 23:16:28', '2026-05-22 23:16:28', '2026-05-22 23:16:28', NULL, 'add82dc118e2081500f19cbb321272d4', 1) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -2495,3 +2495,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:41:28', NULL, '4c0eebba4edb4394fb47ba2d81ef42ad', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:43:45', NULL, '1ce891624631502279b0356c5cdffae2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 22:50:41', NULL, 'a5f4b20a0a37332ab35de8fa07e22298', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 23:16:28', NULL, 'bd5e9cc5ae0e8bdbac4ad8cc57b33b58', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+13 -13
View File
@@ -10,7 +10,7 @@
/// `tmuxPaneId` (`%N`, empty for the lead), `name`, `agentType`, `model`,
/// `color`, `cwd`, `joinedAt`. Polling `tmux -L clide list-panes -a` and
/// correlating live pane ids with `tmuxPaneId` gives a dependable
/// born/died signal and full identity — no transcript needed.
/// joined/left signal and full identity — no transcript needed.
/// - **Fragile — per-teammate transcript join.** A teammate's transcript is
/// a subagent file `<munged-cwd>/<leadSessionId>/subagents/agent-<hex>.jsonl`
/// whose only ids are a random hex (the filename) and a `slug`; it carries
@@ -137,8 +137,8 @@ class _LiveMember {
final TranscriptPublisher? publisher;
}
/// Watches a workspace's tmux team and emits [TeamMemberBorn] /
/// [TeamMemberDied] as panes appear/disappear, publishing each teammate's
/// Watches a workspace's tmux team and emits [TeamMemberJoined] /
/// [TeamMemberLeft] as panes appear/disappear, publishing each teammate's
/// transcript onto the [MessageBus] under its per-agent channel.
class TeamObserver {
TeamObserver({
@@ -200,7 +200,7 @@ class TeamObserver {
}
/// One poll cycle (public for tests). Diffs the config roster against the
/// live panes and emits born/died.
/// live panes and emits joined/left.
Future<void> tick() async {
final config = await discoverTeam(workspacePath, teamsBase: _teamsBase);
if (config == null) {
@@ -215,19 +215,19 @@ class TeamObserver {
final paneLive = livePanes.contains(m.tmuxPaneId);
final tracked = _live.containsKey(m.agentId);
if (paneLive && !tracked) {
await _born(config, m);
await _joined(config, m);
} else if (!paneLive && tracked) {
await _died(m.agentId);
await _left(m.agentId);
}
}
// A member dropped from the config (team reshaped) also counts as died.
// A member dropped from the config (team reshaped) also counts as left.
for (final id in _live.keys.toList()) {
if (!configIds.contains(id)) await _died(id);
if (!configIds.contains(id)) await _left(id);
}
}
Future<void> _born(TeamConfig config, TeamMember m) async {
Future<void> _joined(TeamConfig config, TeamMember m) async {
final path = await _resolveTranscript(config, m);
TranscriptPublisher? pub;
if (path != null) {
@@ -238,7 +238,7 @@ class TeamObserver {
);
}
_live[m.agentId] = _LiveMember(m, config.team, pub);
_events.emit(TeamMemberBorn(
_events.emit(TeamMemberJoined(
team: config.team,
agentId: m.agentId,
name: m.name,
@@ -251,16 +251,16 @@ class TeamObserver {
));
}
Future<void> _died(String agentId) async {
Future<void> _left(String agentId) async {
final live = _live.remove(agentId);
if (live == null) return;
await live.publisher?.dispose();
_events.emit(TeamMemberDied(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId));
_events.emit(TeamMemberLeft(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId));
}
Future<void> _killAll() async {
for (final id in _live.keys.toList()) {
await _died(id);
await _left(id);
}
}
+6 -6
View File
@@ -111,8 +111,8 @@ class DaemonEvent extends ClideEvent {
/// taken from the team config, so it is reliable regardless of transcript
/// drift (T-139, D-75). [transcriptPath] is the best-effort resolved
/// subagent transcript, or null if it could not be joined yet.
class TeamMemberBorn extends ClideEvent {
const TeamMemberBorn({
class TeamMemberJoined extends ClideEvent {
const TeamMemberJoined({
required this.team,
required this.agentId,
required this.name,
@@ -142,7 +142,7 @@ class TeamMemberBorn extends ClideEvent {
@override
String get subsystem => 'team';
@override
String get kind => 'member-born';
String get kind => 'member-joined';
@override
Map<String, Object?> payload() => {
'team': team,
@@ -159,8 +159,8 @@ class TeamMemberBorn extends ClideEvent {
/// A Claude Code tmux teammate's pane went away (it exited or the team
/// dissolved) — T-139.
class TeamMemberDied extends ClideEvent {
const TeamMemberDied({
class TeamMemberLeft extends ClideEvent {
const TeamMemberLeft({
required this.team,
required this.agentId,
required this.paneId,
@@ -173,7 +173,7 @@ class TeamMemberDied extends ClideEvent {
@override
String get subsystem => 'team';
@override
String get kind => 'member-died';
String get kind => 'member-left';
@override
Map<String, Object?> payload() => {'team': team, 'agentId': agentId, 'paneId': paneId};
}
+33 -33
View File
@@ -1,5 +1,5 @@
/// Tests for the tmux team observer (T-139). Pure Dart (no Flutter):
/// config parsing/discovery, the config-driven born/died lifecycle, and
/// config parsing/discovery, the config-driven joined/left lifecycle, and
/// the best-effort subagent-transcript join — all exercised against
/// on-disk fixtures, mirroring how the T-134 spike validated CC's
/// undocumented team artifacts.
@@ -54,8 +54,8 @@ Future<Directory> _writeTeam(Directory teamsBase, String team, String json) asyn
void main() {
group('team events', () {
test('TeamMemberBorn payload carries identity + optional fields', () {
const e = TeamMemberBorn(
test('TeamMemberJoined payload carries identity + optional fields', () {
const e = TeamMemberJoined(
team: 'myteam',
agentId: 'alice@myteam',
name: 'alice',
@@ -67,7 +67,7 @@ void main() {
transcriptPath: '/t/agent-a.jsonl',
);
expect(e.subsystem, 'team');
expect(e.kind, 'member-born');
expect(e.kind, 'member-joined');
expect(e.payload(), {
'team': 'myteam',
'agentId': 'alice@myteam',
@@ -81,15 +81,15 @@ void main() {
});
});
test('TeamMemberBorn omits null optional fields', () {
const e = TeamMemberBorn(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1');
test('TeamMemberJoined omits null optional fields', () {
const e = TeamMemberJoined(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1');
expect(e.payload().keys, ['team', 'agentId', 'name', 'agentType', 'paneId']);
});
test('TeamMemberDied payload', () {
const e = TeamMemberDied(team: 't', agentId: 'a@t', paneId: '%1');
test('TeamMemberLeft payload', () {
const e = TeamMemberLeft(team: 't', agentId: 'a@t', paneId: '%1');
expect(e.subsystem, 'team');
expect(e.kind, 'member-died');
expect(e.kind, 'member-left');
expect(e.payload(), {'team': 't', 'agentId': 'a@t', 'paneId': '%1'});
});
});
@@ -145,18 +145,18 @@ void main() {
late Directory projectsBase;
late DaemonBus events;
late MessageBus messages;
late List<TeamMemberBorn> born;
late List<TeamMemberDied> died;
late List<TeamMemberJoined> joined;
late List<TeamMemberLeft> left;
setUp(() async {
teamsBase = await Directory.systemTemp.createTemp('teams_');
projectsBase = await Directory.systemTemp.createTemp('projects_');
events = DaemonBus();
messages = MessageBus();
born = [];
died = [];
events.on<TeamMemberBorn>().listen(born.add);
events.on<TeamMemberDied>().listen(died.add);
joined = [];
left = [];
events.on<TeamMemberJoined>().listen(joined.add);
events.on<TeamMemberLeft>().listen(left.add);
});
tearDown(() async {
@@ -168,7 +168,7 @@ void main() {
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 10));
test('emits born when a teammate pane is live, died when it goes', () async {
test('emits joined when a teammate pane is live, left when it goes', () async {
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
var panes = {'%5'};
final obs = TeamObserver(
@@ -183,21 +183,21 @@ void main() {
await obs.tick();
await settle();
expect(born.map((b) => b.name), ['alice']);
expect(born.single.paneId, '%5');
expect(born.single.agentId, 'alice@myteam');
expect(died, isEmpty);
expect(joined.map((b) => b.name), ['alice']);
expect(joined.single.paneId, '%5');
expect(joined.single.agentId, 'alice@myteam');
expect(left, isEmpty);
// Same pane still live -> no duplicate born.
// Same pane still live -> no duplicate joined.
await obs.tick();
await settle();
expect(born, hasLength(1));
expect(joined, hasLength(1));
// Pane gone -> died.
// Pane gone -> left.
panes = {};
await obs.tick();
await settle();
expect(died.map((d) => d.agentId), ['alice@myteam']);
expect(left.map((d) => d.agentId), ['alice@myteam']);
});
test('start() polls on a timer and dispose() stops it', () async {
@@ -212,16 +212,16 @@ void main() {
pollInterval: const Duration(milliseconds: 20),
);
obs.start();
// Poll until the timer-driven tick emits born (or time out).
// Poll until the timer-driven tick emits joined (or time out).
final deadline = DateTime.now().add(const Duration(seconds: 2));
while (born.isEmpty && DateTime.now().isBefore(deadline)) {
while (joined.isEmpty && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
expect(born.map((b) => b.name), ['alice']);
expect(joined.map((b) => b.name), ['alice']);
await obs.dispose();
// dispose emits died for the tracked member.
// dispose emits left for the tracked member.
await settle();
expect(died.map((d) => d.agentId), ['alice@myteam']);
expect(left.map((d) => d.agentId), ['alice@myteam']);
});
test('constructs with default base dirs / pane lister', () {
@@ -242,8 +242,8 @@ void main() {
addTearDown(obs.dispose);
await obs.tick();
await settle();
expect(born, isEmpty);
expect(died, isEmpty);
expect(joined, isEmpty);
expect(left, isEmpty);
});
test('joins the teammate transcript via a matching .meta.json', () async {
@@ -266,7 +266,7 @@ void main() {
await obs.tick();
await settle();
expect(born.single.transcriptPath, endsWith('agent-aaa111.jsonl'));
expect(joined.single.transcriptPath, endsWith('agent-aaa111.jsonl'));
});
test('falls back to joinedAt<->mtime order when no .meta.json', () async {
@@ -300,7 +300,7 @@ void main() {
await obs.tick();
await settle();
final byName = {for (final b in born) b.name: b.transcriptPath};
final byName = {for (final b in joined) b.name: b.transcriptPath};
expect(byName['first'], endsWith('agent-older.jsonl'));
expect(byName['second'], endsWith('agent-newer.jsonl'));
});