@@ -2415,7 +2415,17 @@ async function handleStandaloneCommentTrigger(args: {
24152415 if ( await maybeResumeClarifyHold ( { issueId, task, workspaceId, commentId, replyTargetId, trigger, resolved, registryTableName } ) ) {
24162416 return ;
24172417 }
2418- logger . info ( 'A6 comment (standalone): ABCA task has no resolvable PR/repo — cannot iterate' , {
2418+ // #614: a PR-less completed task (no-change-needed, failed-before-commit, or
2419+ // a question/investigation run) is NOT an iteration target — but a follow-up
2420+ // ``@bgagent <request>`` on it is almost always NEW work ("then just do X
2421+ // instead"). When the repo is known, dispatch a fresh new-task-v1 rather than
2422+ // dropping the comment silently (the old dead-end). Falls through to the
2423+ // no-op log below only when we genuinely can't act (no repo/user, or a bare
2424+ // mention with no instruction).
2425+ if ( await maybeStartStandaloneNewWork ( { issueId, task, workspaceId, commentId, replyTargetId, trigger, resolved, registryTableName } ) ) {
2426+ return ;
2427+ }
2428+ logger . info ( 'A6 comment (standalone): PR-less task, no new-work dispatched (no repo/user or empty instruction)' , {
24192429 linear_issue_id : issueId , task_id : task . task_id , has_repo : Boolean ( task . repo ) ,
24202430 } ) ;
24212431 return ;
@@ -2470,6 +2480,112 @@ async function handleStandaloneCommentTrigger(args: {
24702480 }
24712481}
24722482
2483+ /**
2484+ * #614: start NEW work from a follow-up comment on a PR-less completed task.
2485+ *
2486+ * The standalone path only knows how to *iterate* an existing PR. But a task
2487+ * can finish with no PR (no change needed, failed before committing, or a
2488+ * question/investigation run), and a follow-up ``@bgagent <request>`` on such an
2489+ * issue is almost always a fresh ask ("then just do X instead") — not iteration.
2490+ * Before #614 those comments hit a silent ``return`` and vanished. This dispatches
2491+ * a fresh ``coding/new-task-v1`` against the SAME repo, using the comment text as
2492+ * the task description, with the same 👀-ack + threaded reply + fanout terminal
2493+ * ownership as the iteration/clarify paths.
2494+ *
2495+ * Returns true when it handled the comment (a task was dispatched, OR a bare
2496+ * mention was answered with a "nothing to do" reply), false when it cannot act
2497+ * (no repo/user) so the caller falls through to its no-op log.
2498+ *
2499+ * Best-effort: a dispatch failure is logged and still returns true (we already
2500+ * ACKed) — the fanout terminal path reports the outcome.
2501+ */
2502+ async function maybeStartStandaloneNewWork ( args : {
2503+ issueId : string ;
2504+ task : { task_id : string ; repo ?: string ; user_id ?: string } ;
2505+ workspaceId : string ;
2506+ commentId : string ;
2507+ replyTargetId : string ;
2508+ trigger : CommentTrigger ;
2509+ resolved : { accessToken : string ; oauthSecretArn : string ; workspaceSlug : string } ;
2510+ registryTableName : string ;
2511+ } ) : Promise < boolean > {
2512+ const { issueId, task, workspaceId, commentId, replyTargetId, trigger, resolved, registryTableName } = args ;
2513+
2514+ // Can't act without a repo to work in or a user to attribute the task to —
2515+ // let the caller no-op-log. (These are the only genuinely unactionable cases.)
2516+ if ( ! task . repo || ! task . user_id ) return false ;
2517+
2518+ const instruction = trigger . instruction . trim ( ) ;
2519+ const feedbackCtx = { linearWorkspaceId : workspaceId , registryTableName } ;
2520+
2521+ // A bare ``@bgagent`` with no text has nothing to start. Unlike iteration
2522+ // (where an empty instruction means "address the latest review"), there is no
2523+ // PR to fall back on here — so acknowledge briefly rather than dispatch a
2524+ // vague task or stay silent. Handled (return true) so we don't no-op-log.
2525+ if ( ! instruction ) {
2526+ await reactToComment ( feedbackCtx , commentId , EMOJI_STARTED ) ;
2527+ try {
2528+ await upsertThreadedReply (
2529+ feedbackCtx ,
2530+ issueId ,
2531+ replyTargetId ,
2532+ 'This task already finished and has no open PR to iterate on. Reply with what '
2533+ + "you'd like me to do (e.g. `@bgagent add a note to the README`) and I'll start it." ,
2534+ ) ;
2535+ } catch ( err ) {
2536+ logger . warn ( 'A6 comment (standalone): bare-mention reply failed (non-fatal)' , {
2537+ linear_issue_id : issueId , error : err instanceof Error ? err . message : String ( err ) ,
2538+ } ) ;
2539+ }
2540+ logger . info ( 'A6 comment (standalone): bare mention on PR-less task — replied, no dispatch' , {
2541+ linear_issue_id : issueId , task_id : task . task_id ,
2542+ } ) ;
2543+ return true ;
2544+ }
2545+
2546+ // ACK immediately (👀 reaction + threaded "On it"), same as the iteration and
2547+ // clarify-resume paths.
2548+ await reactToComment ( feedbackCtx , commentId , EMOJI_STARTED ) ;
2549+ const iterationReplyId = await postIterationAck ( workspaceId , registryTableName , issueId , replyTargetId ) ;
2550+
2551+ // Idempotency: key on (issue, comment) so a webhook redelivery of the SAME
2552+ // comment doesn't spawn a second task. Distinct prefix from iterate_/clarify_.
2553+ const idempotencyKey = `newwork_${ issueId } _${ commentId } ` . replace ( / [ ^ A - Z a - z 0 - 9 _ - ] / g, '' ) . slice ( 0 , MAX_IDEMPOTENCY_KEY_LENGTH ) ;
2554+ const channelMetadata : Record < string , string > = {
2555+ // NO orchestration_id / orchestration_iteration — the reconciler skips this;
2556+ // the fanout dispatcher posts the ✅/❌ reply on terminal. Reply to the thread
2557+ // ROOT (replyTargetId), never to a reply.
2558+ trigger_comment_id : replyTargetId ,
2559+ linear_issue_id : issueId ,
2560+ linear_workspace_id : workspaceId ,
2561+ linear_oauth_secret_arn : resolved . oauthSecretArn ,
2562+ linear_workspace_slug : resolved . workspaceSlug ,
2563+ ...( iterationReplyId && { iteration_reply_comment_id : iterationReplyId } ) ,
2564+ } ;
2565+
2566+ try {
2567+ const result = await createTaskCore (
2568+ {
2569+ repo : task . repo ,
2570+ workflow_ref : 'coding/new-task-v1' ,
2571+ task_description : instruction ,
2572+ } ,
2573+ { userId : task . user_id , channelSource : 'linear' , channelMetadata, idempotencyKey } ,
2574+ idempotencyKey ,
2575+ ) ;
2576+ logger . info ( 'A6 comment (standalone): fresh new-task dispatched from follow-up on PR-less task' , {
2577+ linear_issue_id : issueId , prior_task_id : task . task_id , status_code : result . statusCode ,
2578+ } ) ;
2579+ } catch ( err ) {
2580+ logger . error ( 'A6 comment (standalone): createTaskCore threw for new-work dispatch' , {
2581+ linear_issue_id : issueId ,
2582+ prior_task_id : task . task_id ,
2583+ error : err instanceof Error ? err . message : String ( err ) ,
2584+ } ) ;
2585+ }
2586+ return true ;
2587+ }
2588+
24732589/**
24742590 * PM-1 clarify-resume. A ``coding/new-task-v1`` run can HOLD to ask a
24752591 * clarifying question (no PR, ``code_changed=false``, ``answer_text=<question>``;
0 commit comments