Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/services/OrchestrationLogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,25 @@ export class OrchestrationLogService {
// Assign to first participant from the log entry
const assignee = entry.participants[0] ?? 'unknown';

// Check if any outcome mentions this issue with a completion signal
let isCompleted = false;
if (entry.outcomes) {
for (const outcome of entry.outcomes) {
if (outcome.includes(`#${taskId}`) && this.isCompletionSignal(outcome)) {
isCompleted = true;
break;
}
}
}

tasks.push({
id: taskId,
title: `Issue #${taskId}`,
description: entry.summary ?? undefined,
status: 'in_progress',
status: isCompleted ? 'completed' : 'in_progress',
assignee,
startedAt: parseDateAsLocal(entry.date),
completedAt: isCompleted ? parseDateAsLocal(entry.date) : undefined,
});
}
}
Expand Down
19 changes: 14 additions & 5 deletions src/services/SquadDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,12 @@
members = roster.members.map(member => {
const logStatus = memberStates.get(member.name) ?? 'idle';
const currentTask = tasks.find(t => t.assignee === member.name && t.status === 'in_progress');
// Override 'working' to 'idle' if the member has no in-progress tasks
// (all their work is completed — they shouldn't show as spinning)
const status = (logStatus === 'working' && !currentTask) ? 'idle' : logStatus;
const memberTasks = tasks.filter(t => t.assignee === member.name);
// Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress
// (all their work is completed — they shouldn't show as spinning).
// If they have NO tasks at all, trust the log status (Copilot Chat scenario).
const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask;
const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus;
return {
name: member.name,
role: member.role,
Expand All @@ -119,7 +122,10 @@
members = agentMembers.map(member => {
const logStatus = memberStates.get(member.name) ?? 'idle';
const currentTask = tasks.find(t => t.assignee === member.name && t.status === 'in_progress');
const status = (logStatus === 'working' && !currentTask) ? 'idle' : logStatus;
const memberTasks = tasks.filter(t => t.assignee === member.name);
// Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress
const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask;
const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus;
return {
name: member.name,
role: member.role,
Expand All @@ -140,7 +146,10 @@
for (const name of memberNames) {
const logStatus = memberStates.get(name) ?? 'idle';
const currentTask = tasks.find(t => t.assignee === name && t.status === 'in_progress');
const status = (logStatus === 'working' && !currentTask) ? 'idle' : logStatus;
const memberTasks = tasks.filter(t => t.assignee === name);
// Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress
const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask;
const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus;
members.push({
name,
role: 'Squad Member',
Expand Down Expand Up @@ -210,7 +219,7 @@

return {
task,
member: placeholderMember as any,

Check warning on line 222 in src/services/SquadDataProvider.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

Unexpected any. Specify a different type

Check warning on line 222 in src/services/SquadDataProvider.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Unexpected any. Specify a different type

Check warning on line 222 in src/services/SquadDataProvider.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Unexpected any. Specify a different type
};
}

Expand Down
85 changes: 85 additions & 0 deletions src/test/suite/orchestrationLogService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as assert from 'assert';
import * as path from 'path';
import * as fs from 'fs';
import { OrchestrationLogService } from '../../services/OrchestrationLogService';
import { OrchestrationLogEntry } from '../../models';

const TEST_FIXTURES_ROOT = path.resolve(__dirname, '../../../test-fixtures');

Expand Down Expand Up @@ -131,6 +132,90 @@ suite('OrchestrationLogService — Table Format Extraction', () => {
});
});

suite('getActiveTasks() — relatedIssues completion signal detection (issue #63)', () => {
test('relatedIssues with "Closed #NN" outcome → task status = completed', () => {
const entries: OrchestrationLogEntry[] = [
{
timestamp: '2026-03-01T00:00:00Z',
date: '2026-03-01',
topic: 'fixed-bug',
participants: ['Alice'],
summary: 'Fixed authentication bug',
relatedIssues: ['#42'],
outcomes: ['Closed #42 — fixed the authentication bug'],
},
];

const tasks = service.getActiveTasks(entries);

const task42 = tasks.find(t => t.id === '42');
assert.ok(task42, 'Should find task #42');
assert.strictEqual(task42!.status, 'completed', 'Task should be completed due to "Closed #42" signal');
assert.ok(task42!.completedAt, 'Task should have completedAt timestamp');
});

test('relatedIssues with "Fixed #NN" outcome → task status = completed', () => {
const entries: OrchestrationLogEntry[] = [
{
timestamp: '2026-03-02T00:00:00Z',
date: '2026-03-02',
topic: 'bug-fix',
participants: ['Bob'],
summary: 'Bug fix',
relatedIssues: ['#99'],
outcomes: ['Resolved #99 in latest commit'],
},
];

const tasks = service.getActiveTasks(entries);

const task99 = tasks.find(t => t.id === '99');
assert.ok(task99, 'Should find task #99');
assert.strictEqual(task99!.status, 'completed', 'Task should be completed due to "Resolved" signal');
});

test('relatedIssues with "Working on #NN" outcome → task status = in_progress', () => {
const entries: OrchestrationLogEntry[] = [
{
timestamp: '2026-03-03T00:00:00Z',
date: '2026-03-03',
topic: 'active-work',
participants: ['Carol'],
summary: 'Active work',
relatedIssues: ['#77'],
outcomes: ['Working on #77 — implementing new feature'],
},
];

const tasks = service.getActiveTasks(entries);

const task77 = tasks.find(t => t.id === '77');
assert.ok(task77, 'Should find task #77');
assert.strictEqual(task77!.status, 'in_progress', 'Task should be in_progress without completion signal');
assert.strictEqual(task77!.completedAt, undefined, 'Task should not have completedAt timestamp');
});

test('relatedIssues without matching outcomes → task status = in_progress', () => {
const entries: OrchestrationLogEntry[] = [
{
timestamp: '2026-03-04T00:00:00Z',
date: '2026-03-04',
topic: 'start-feature',
participants: ['Dave'],
summary: 'Started investigating the issue',
relatedIssues: ['#55'],
},
];

const tasks = service.getActiveTasks(entries);

const task55 = tasks.find(t => t.id === '55');
assert.ok(task55, 'Should find task #55');
assert.strictEqual(task55!.status, 'in_progress', 'Task should be in_progress by default');
assert.strictEqual(task55!.completedAt, undefined, 'Task should not have completedAt timestamp');
});
});

suite('parseLogFile() — table-format integration', () => {
let tempDir: string;

Expand Down
98 changes: 96 additions & 2 deletions src/test/suite/squadDataProviderExtended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ suite('SquadDataProvider — Extended Coverage', () => {

// ─── getSquadMembers() — Status Override Logic ──────────────────────

suite('getSquadMembers() — working-to-idle override', () => {
suite('getSquadMembers() — working-to-idle override (issue #63)', () => {
test('member shown as idle when log says working but no in-progress tasks', async () => {
// Create team.md
fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [
Expand All @@ -167,14 +167,17 @@ suite('SquadDataProvider — Extended Coverage', () => {
].join('\n'));

// Create log that marks Alice as participant (most recent = working)
// but with completed tasks only
// but with completed tasks only (issue reference with completion signal)
const logDir = path.join(tempDir, '.ai-team', 'orchestration-log');
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(path.join(logDir, '2026-03-01-completed.md'), [
'# Completed Work',
'',
'**Participants:** Alice',
'',
'## Outcomes',
'- Closed #42 — fixed the bug',
'',
'## Summary',
'All tasks completed.',
].join('\n'));
Expand All @@ -187,5 +190,96 @@ suite('SquadDataProvider — Extended Coverage', () => {
// Alice should be idle because no in-progress tasks exist
assert.strictEqual(alice!.status, 'idle', 'Should be idle when no in-progress tasks');
});

test('member stays working when log says working and has no tasks at all (Copilot Chat)', async () => {
fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [
'# Team',
'',
'## Members',
'',
'| Name | Role | Charter | Status |',
'|------|------|---------|--------|',
'| Bob | Dev | `.ai-team/agents/bob/charter.md` | ✅ Active |',
].join('\n'));

// Create log that marks Bob as participant but NO tasks or issue refs at all
const logDir = path.join(tempDir, '.ai-team', 'orchestration-log');
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(path.join(logDir, '2026-03-01-chat.md'), [
'# Copilot Chat Session',
'',
'**Participants:** Bob',
'',
'## Summary',
'Answered questions about the codebase.',
].join('\n'));

const provider = new SquadDataProvider(tempDir, '.ai-team');
const members = await provider.getSquadMembers();

const bob = members.find(m => m.name === 'Bob');
assert.ok(bob, 'Should find Bob');
// Bob has no tasks at all, so should stay "working" (Copilot Chat scenario)
assert.strictEqual(bob!.status, 'working', 'Should stay working when no tasks exist');
});

test('member stays working when log says working and has in-progress tasks', async () => {
fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [
'# Team',
'',
'## Members',
'',
'| Name | Role | Charter | Status |',
'|------|------|---------|--------|',
'| Carol | Dev | `.ai-team/agents/carol/charter.md` | ✅ Active |',
].join('\n'));

// Create log that marks Carol as participant with in-progress task
const logDir = path.join(tempDir, '.ai-team', 'orchestration-log');
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(path.join(logDir, '2026-03-01-in-progress.md'), [
'# Active Work',
'',
'**Participants:** Carol',
'',
'## Related Issues',
'- #99',
'',
'## Summary',
'Working on new feature.',
].join('\n'));

const provider = new SquadDataProvider(tempDir, '.ai-team');
const members = await provider.getSquadMembers();

const carol = members.find(m => m.name === 'Carol');
assert.ok(carol, 'Should find Carol');
// Carol has an in-progress task, should stay "working"
assert.strictEqual(carol!.status, 'working', 'Should stay working with in-progress tasks');
});

test('member not in logs shows as idle', async () => {
fs.writeFileSync(path.join(tempDir, '.ai-team', 'team.md'), [
'# Team',
'',
'## Members',
'',
'| Name | Role | Charter | Status |',
'|------|------|---------|--------|',
'| Dave | Dev | `.ai-team/agents/dave/charter.md` | ✅ Active |',
].join('\n'));

// Create log directory but no entries for Dave
const logDir = path.join(tempDir, '.ai-team', 'orchestration-log');
fs.mkdirSync(logDir, { recursive: true });

const provider = new SquadDataProvider(tempDir, '.ai-team');
const members = await provider.getSquadMembers();

const dave = members.find(m => m.name === 'Dave');
assert.ok(dave, 'Should find Dave');
// Dave not in any logs, should be "idle"
assert.strictEqual(dave!.status, 'idle', 'Should be idle when not in logs');
});
});
});
Loading