feat: enhance TeamDataService and TeamProvisioningService for improved task management and user DM forwarding
- Updated TeamDataService to ensure consistent kanban task status updates with rollback capabilities on failure. - Introduced silentUserDmForward functionality in TeamProvisioningService to manage internal DM forwarding to teammates without user echo. - Enhanced task status update logic in TeamTaskWriter to prevent redundant updates. - Improved ClaudeLogsSection to support search query overrides for better log filtering in the UI.
This commit is contained in:
parent
3a8179d980
commit
d2bb734af6
4 changed files with 88 additions and 2 deletions
|
|
@ -1367,7 +1367,34 @@ export class TeamDataService {
|
||||||
|
|
||||||
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
||||||
if (patch.op !== 'request_changes') {
|
if (patch.op !== 'request_changes') {
|
||||||
|
// Keep kanban + task.status consistent:
|
||||||
|
// - moving a task into kanban review/approved implies the work is complete
|
||||||
|
// - request_changes already moves it back to in_progress and clears kanban entry
|
||||||
|
if (patch.op !== 'set_column') {
|
||||||
|
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousState = await this.kanbanManager.getState(teamName);
|
||||||
|
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId];
|
||||||
|
|
||||||
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user');
|
||||||
|
} catch (error) {
|
||||||
|
// Best-effort rollback of kanban move if task status update failed.
|
||||||
|
if (previousKanbanEntry) {
|
||||||
|
await this.kanbanManager
|
||||||
|
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
await this.kanbanManager
|
||||||
|
.updateTask(teamName, taskId, { op: 'remove' })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,11 @@ interface ProvisioningRun {
|
||||||
* Flushed to liveLeadProcessMessages on result.success.
|
* Flushed to liveLeadProcessMessages on result.success.
|
||||||
*/
|
*/
|
||||||
directReplyParts: string[];
|
directReplyParts: string[];
|
||||||
|
/**
|
||||||
|
* When set, the current stdin-injected turn is an internal "forward user DM to teammate"
|
||||||
|
* request triggered by the UI. We suppress any lead→user echo for that turn.
|
||||||
|
*/
|
||||||
|
silentUserDmForward: { target: string; startedAt: string } | null;
|
||||||
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
||||||
provisioningOutputParts: string[];
|
provisioningOutputParts: string[];
|
||||||
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
||||||
|
|
@ -1678,6 +1683,7 @@ export class TeamProvisioningService {
|
||||||
fsPhase: 'waiting_config',
|
fsPhase: 'waiting_config',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
|
silentUserDmForward: null,
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
detectedSessionId: null,
|
detectedSessionId: null,
|
||||||
leadActivityState: 'active',
|
leadActivityState: 'active',
|
||||||
|
|
@ -1967,6 +1973,7 @@ export class TeamProvisioningService {
|
||||||
fsPhase: 'waiting_members',
|
fsPhase: 'waiting_members',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
|
silentUserDmForward: null,
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
detectedSessionId: null,
|
detectedSessionId: null,
|
||||||
leadActivityState: 'active',
|
leadActivityState: 'active',
|
||||||
|
|
@ -2208,6 +2215,52 @@ export class TeamProvisioningService {
|
||||||
this.setLeadActivity(run, 'active');
|
this.setLeadActivity(run, 'active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort: forward a user-written DM to a teammate via the live lead process.
|
||||||
|
* This covers cases where teammates don't automatically respond to inbox JSON,
|
||||||
|
* and only react to Claude Code internal SendMessage routing.
|
||||||
|
*
|
||||||
|
* Note: We suppress the lead's textual output for this injected turn to avoid
|
||||||
|
* confusing lead responses like "No action needed."
|
||||||
|
*/
|
||||||
|
async forwardUserDmToTeammate(
|
||||||
|
teamName: string,
|
||||||
|
teammateName: string,
|
||||||
|
userText: string,
|
||||||
|
userSummary?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const runId = this.activeByTeam.get(teamName);
|
||||||
|
if (!runId) {
|
||||||
|
throw new Error(`No active process for team "${teamName}"`);
|
||||||
|
}
|
||||||
|
const run = this.runs.get(runId);
|
||||||
|
if (!run?.child?.stdin?.writable) {
|
||||||
|
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
||||||
|
}
|
||||||
|
if (!run.provisioningComplete) {
|
||||||
|
// Don't inject extra turns during provisioning/bootstrap.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() };
|
||||||
|
|
||||||
|
const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null;
|
||||||
|
const message = [
|
||||||
|
`INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`,
|
||||||
|
`Action: forward it to that teammate using the SendMessage tool.`,
|
||||||
|
`IMPORTANT: Do NOT reply to the human user for this turn.`,
|
||||||
|
`In the forwarded message, ask the teammate to reply to recipient "user" with a short answer.`,
|
||||||
|
``,
|
||||||
|
`User message:`,
|
||||||
|
...(summaryLine ? [summaryLine] : []),
|
||||||
|
userText,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
await this.sendMessageToTeam(teamName, message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay unread inbox messages addressed to the team lead into the live lead process.
|
* Relay unread inbox messages addressed to the team lead into the live lead process.
|
||||||
*
|
*
|
||||||
|
|
@ -2731,7 +2784,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Accumulate assistant text for direct user→lead messages (no relay capture).
|
// Accumulate assistant text for direct user→lead messages (no relay capture).
|
||||||
run.directReplyParts.push(text);
|
if (!run.silentUserDmForward) {
|
||||||
|
run.directReplyParts.push(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2740,7 +2795,7 @@ export class TeamProvisioningService {
|
||||||
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
||||||
// from stdout and persist them to sentMessages.json under the correct team name,
|
// from stdout and persist them to sentMessages.json under the correct team name,
|
||||||
// ensuring the UI and notifications show the right team.
|
// ensuring the UI and notifications show the right team.
|
||||||
if (run.provisioningComplete) {
|
if (run.provisioningComplete && !run.silentUserDmForward) {
|
||||||
this.captureSendMessageToUser(run, content ?? []);
|
this.captureSendMessageToUser(run, content ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,9 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
const task = JSON.parse(raw) as TeamTask;
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
const prevStatus = task.status;
|
const prevStatus = task.status;
|
||||||
|
if (prevStatus === status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
// Maintain workIntervals as periods of time where status === 'in_progress'.
|
// Maintain workIntervals as periods of time where status === 'in_progress'.
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
// Parser expects chronological order; UI shows newest-first.
|
// Parser expects chronological order; UI shows newest-first.
|
||||||
cliLogsTail={filteredText}
|
cliLogsTail={filteredText}
|
||||||
order="newest-first"
|
order="newest-first"
|
||||||
|
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||||
className="max-h-[320px] p-2"
|
className="max-h-[320px] p-2"
|
||||||
containerRefCallback={(el) => {
|
containerRefCallback={(el) => {
|
||||||
logContainerRef.current = el;
|
logContainerRef.current = el;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue