diff --git a/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php b/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php
index 1b9750a100..d24b997b1f 100644
--- a/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php
+++ b/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php
@@ -1,107 +1,110 @@
 <?php
 
 final class DifferentialDoorkeeperRevisionFeedStoryPublisher
   extends DoorkeeperFeedStoryPublisher {
 
   public function canPublishStory(PhabricatorFeedStory $story, $object) {
     return ($object instanceof DifferentialRevision);
   }
 
   public function isStoryAboutObjectCreation($object) {
     $story = $this->getFeedStory();
+    $action = $story->getStoryData()->getValue('action');
+
+    return ($action == DifferentialAction::ACTION_CREATE);
+  }
 
+  public function isStoryAboutObjectClosure($object) {
+    $story = $this->getFeedStory();
     $action = $story->getStoryData()->getValue('action');
-    switch ($action) {
-      case DifferentialAction::ACTION_CREATE:
-        return true;
-      default:
-        return false;
-    }
+
+    return ($action == DifferentialAction::ACTION_CLOSE) ||
+           ($action == DifferentialAction::ACTION_ABANDON);
   }
 
   public function willPublishStory($object) {
     return id(new DifferentialRevisionQuery())
       ->setViewer($this->getViewer())
       ->withIDs(array($object->getID()))
       ->needRelationships(true)
       ->executeOne();
   }
 
   public function getOwnerPHID($object) {
     return $object->getAuthorPHID();
   }
 
   public function getActiveUserPHIDs($object) {
     $status = $object->getStatus();
     if ($status == ArcanistDifferentialRevisionStatus::NEEDS_REVIEW) {
       return $object->getReviewers();
     } else {
       return array();
     }
   }
 
   public function getPassiveUserPHIDs($object) {
     $status = $object->getStatus();
     if ($status == ArcanistDifferentialRevisionStatus::NEEDS_REVIEW) {
       return array();
     } else {
       return $object->getReviewers();
     }
   }
 
   public function getCCUserPHIDs($object) {
     return $object->getCCPHIDs();
   }
 
   public function getObjectTitle($object) {
     $prefix = $this->getTitlePrefix($object);
 
     $lines = new PhutilNumber($object->getLineCount());
     $lines = pht('[Request, %d lines]', $lines);
 
     $id = $object->getID();
 
     $title = $object->getTitle();
 
     return ltrim("{$prefix} {$lines} D{$id}: {$title}");
   }
 
   public function getObjectURI($object) {
     return PhabricatorEnv::getProductionURI('/D'.$object->getID());
   }
 
   public function getObjectDescription($object) {
     return $object->getSummary();
   }
 
   public function isObjectClosed($object) {
     switch ($object->getStatus()) {
       case ArcanistDifferentialRevisionStatus::CLOSED:
       case ArcanistDifferentialRevisionStatus::ABANDONED:
         return true;
       default:
         return false;
     }
   }
 
   public function getResponsibilityTitle($object) {
     $prefix = $this->getTitlePrefix($object);
     return pht('%s Review Request', $prefix);
   }
 
   public function getStoryText($object) {
     $story = $this->getFeedStory();
     if ($story instanceof PhabricatorFeedStoryDifferential) {
       $text = $story->renderForAsanaBridge();
     } else {
       $text = $story->renderText();
     }
     return $text;
   }
 
   private function getTitlePrefix(DifferentialRevision $revision) {
     $prefix_key = 'metamta.differential.subject-prefix';
     return PhabricatorEnv::getEnvConfig($prefix_key);
   }
 
 }
diff --git a/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php b/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php
index 6ded4ad2f8..feae1628ad 100644
--- a/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php
+++ b/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php
@@ -1,170 +1,193 @@
 <?php
 
 final class DiffusionDoorkeeperCommitFeedStoryPublisher
   extends DoorkeeperFeedStoryPublisher {
 
   private $auditRequests;
   private $activePHIDs;
   private $passivePHIDs;
 
   private function getAuditRequests() {
     return $this->auditRequests;
   }
 
   public function canPublishStory(PhabricatorFeedStory $story, $object) {
     return ($object instanceof PhabricatorRepositoryCommit);
   }
 
   public function isStoryAboutObjectCreation($object) {
     // TODO: Although creation stories exist, they currently don't have a
     // primary object PHID set, so they'll never make it here because they
     // won't pass `canPublishStory()`.
     return false;
   }
 
+  public function isStoryAboutObjectClosure($object) {
+    // TODO: This isn't quite accurate, but pretty close: check if this story
+    // is a close (which clearly is about object closure) or is an "Accept" and
+    // the commit is fully audited (which is almost certainly a closure).
+    // After ApplicationTransactions, we could annotate feed stories more
+    // explicitly.
+
+    $story = $this->getFeedStory();
+    $action = $story->getStoryData()->getValue('action');
+
+    if ($action == PhabricatorAuditActionConstants::CLOSE) {
+      return true;
+    }
+
+    $fully_audited = PhabricatorAuditCommitStatusConstants::FULLY_AUDITED;
+    if (($action == PhabricatorAuditActionConstants::ACCEPT) &&
+        $object->getAuditStatus() == $fully_audited) {
+      return true;
+    }
+
+    return false;
+  }
+
   public function willPublishStory($commit) {
     $requests = id(new PhabricatorAuditQuery())
       ->withCommitPHIDs(array($commit->getPHID()))
       ->execute();
 
     // TODO: This is messy and should be generalized, but we don't have a good
     // query for it yet. Since we run in the daemons, just do the easiest thing
     // we can for the moment. Figure out who all of the "active" (need to
     // audit) and "passive" (no action necessary) user are.
 
     $auditor_phids = mpull($requests, 'getAuditorPHID');
     $objects = id(new PhabricatorObjectHandleData($auditor_phids))
       ->setViewer($this->getViewer())
       ->loadObjects();
 
     $active = array();
     $passive = array();
 
     foreach ($requests as $request) {
       $status = $request->getAuditStatus();
       if ($status == PhabricatorAuditStatusConstants::CC) {
         // We handle these specially below.
         continue;
       }
 
       $object = idx($objects, $request->getAuditorPHID());
       if (!$object) {
         continue;
       }
 
       $request_phids = array();
       if ($object instanceof PhabricatorUser) {
         $request_phids = array($object->getPHID());
       } else if ($object instanceof PhabricatorOwnersPackage) {
         $request_phids = PhabricatorOwnersOwner::loadAffiliatedUserPHIDs(
           array($object->getID()));
       } else if ($object instanceof PhabricatorProject) {
         $request_phids = $object->loadMemberPHIDs();
       } else {
         // Dunno what this is.
         $request_phids = array();
       }
 
       switch ($status) {
         case PhabricatorAuditStatusConstants::AUDIT_REQUIRED:
         case PhabricatorAuditStatusConstants::AUDIT_REQUESTED:
         case PhabricatorAuditStatusConstants::CONCERNED:
           $active += array_fuse($request_phids);
           break;
         default:
           $passive += array_fuse($request_phids);
           break;
       }
     }
 
 
 
     // Remove "Active" users from the "Passive" list.
     $passive = array_diff_key($passive, $active);
 
     $this->activePHIDs = $active;
     $this->passivePHIDs = $passive;
     $this->auditRequests = $requests;
 
     return $commit;
   }
 
   public function getOwnerPHID($object) {
     return $object->getAuthorPHID();
   }
 
   public function getActiveUserPHIDs($object) {
     return $this->activePHIDs;
   }
 
   public function getPassiveUserPHIDs($object) {
     return $this->passivePHIDs;
   }
 
   public function getCCUserPHIDs($object) {
     $ccs = array();
     foreach ($this->getAuditRequests() as $request) {
       if ($request->getAuditStatus() == PhabricatorAuditStatusConstants::CC) {
         $ccs[] = $request->getAuditorPHID();
       }
     }
     return $ccs;
   }
 
   public function getObjectTitle($object) {
     $prefix = $this->getTitlePrefix($object);
 
     $repository = $object->getRepository();
     $name = $repository->formatCommitName($object->getCommitIdentifier());
 
     $title = $object->getSummary();
 
     return ltrim("{$prefix} {$name}: {$title}");
   }
 
   public function getObjectURI($object) {
     $repository = $object->getRepository();
     $name = $repository->formatCommitName($object->getCommitIdentifier());
     return PhabricatorEnv::getProductionURI('/'.$name);
   }
 
   public function getObjectDescription($object) {
     $data = $object->loadCommitData();
     if ($data) {
       return $data->getCommitMessage();
     }
     return null;
   }
 
   public function isObjectClosed($object) {
     switch ($object->getAuditStatus()) {
       case PhabricatorAuditCommitStatusConstants::NEEDS_AUDIT:
       case PhabricatorAuditCommitStatusConstants::CONCERN_RAISED:
       case PhabricatorAuditCommitStatusConstants::PARTIALLY_AUDITED:
         return false;
       default:
         return true;
     }
   }
 
   public function getResponsibilityTitle($object) {
     $prefix = $this->getTitlePrefix($object);
     return pht('%s Audit', $prefix);
   }
 
   public function getStoryText($object) {
     $story = $this->getFeedStory();
     if ($story instanceof PhabricatorFeedStoryAudit) {
       $text = $story->renderForAsanaBridge();
     } else {
       $text = $story->renderText();
     }
     return $text;
   }
 
   private function getTitlePrefix(PhabricatorRepositoryCommit $commit) {
     $prefix_key = 'metamta.diffusion.subject-prefix';
     return PhabricatorEnv::getEnvConfig($prefix_key);
   }
 
 }
diff --git a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
index 428a22be67..bd6809d87f 100644
--- a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
+++ b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
@@ -1,50 +1,51 @@
 <?php
 
 abstract class DoorkeeperFeedStoryPublisher {
 
   private $feedStory;
   private $viewer;
 
   public function setFeedStory(PhabricatorFeedStory $feed_story) {
     $this->feedStory = $feed_story;
     return $this;
   }
 
   public function getFeedStory() {
     return $this->feedStory;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   abstract public function canPublishStory(
     PhabricatorFeedStory $story,
     $object);
 
   /**
    * Hook for publishers to mutate the story object, particularly by loading
    * and attaching additional data.
    */
   public function willPublishStory($object) {
     return $object;
   }
 
   abstract public function isStoryAboutObjectCreation($object);
+  abstract public function isStoryAboutObjectClosure($object);
   abstract public function getOwnerPHID($object);
   abstract public function getActiveUserPHIDs($object);
   abstract public function getPassiveUserPHIDs($object);
   abstract public function getCCUserPHIDs($object);
   abstract public function getObjectTitle($object);
   abstract public function getObjectURI($object);
   abstract public function getObjectDescription($object);
   abstract public function isObjectClosed($object);
   abstract public function getResponsibilityTitle($object);
   abstract public function getStoryText($object);
 
 }
diff --git a/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php b/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php
index f23653a744..3f3cf7e209 100644
--- a/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php
+++ b/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php
@@ -1,627 +1,629 @@
 <?php
 
 final class DoorkeeperFeedWorkerAsana extends FeedPushWorker {
 
   private $provider;
   private $publisher;
   private $workspaceID;
   private $feedStory;
   private $storyObject;
 
   private function getProvider() {
     if (!$this->provider) {
       $provider = PhabricatorAuthProviderOAuthAsana::getAsanaProvider();
       if (!$provider) {
         throw new PhabricatorWorkerPermanentFailureException(
           'No Asana provider configured.');
       }
       $this->provider = $provider;
     }
     return $this->provider;
   }
 
   private function getWorkspaceID() {
     if (!$this->workspaceID) {
       $workspace_id = PhabricatorEnv::getEnvConfig('asana.workspace-id');
       if (!$workspace_id) {
         throw new PhabricatorWorkerPermanentFailureException(
           'No workspace Asana ID configured.');
       }
       $this->workspaceID = $workspace_id;
     }
     return $this->workspaceID;
   }
 
   private function getFeedStory() {
     if (!$this->feedStory) {
       $story = $this->loadFeedStory();
       $this->feedStory = $story;
     }
     return $this->feedStory;
   }
 
   private function getViewer() {
     return PhabricatorUser::getOmnipotentUser();
   }
 
   private function getPublisher() {
     return $this->publisher;
   }
 
   private function getStoryObject() {
     if (!$this->storyObject) {
       $story = $this->getFeedStory();
       try {
         $object = $story->getPrimaryObject();
       } catch (Exception $ex) {
         throw new PhabricatorWorkerPermanentFailureException(
           $ex->getMessage());
       }
       $this->storyObject = $object;
     }
     return $this->storyObject;
   }
 
   private function getAsanaTaskData($object) {
     $publisher = $this->getPublisher();
 
     $title = $publisher->getObjectTitle($object);
     $uri = $publisher->getObjectURI($object);
     $description = $publisher->getObjectDescription($object);
     $is_completed = $publisher->isObjectClosed($object);
 
     $notes = array(
       $description,
       $uri,
       $this->getSynchronizationWarning(),
     );
 
     $notes = implode("\n\n", $notes);
 
     return array(
       'name' => $title,
       'notes' => $notes,
       'completed' => $is_completed,
     );
   }
 
   private function getAsanaSubtaskData($object) {
     $publisher = $this->getPublisher();
 
     $title = $publisher->getResponsibilityTitle($object);
     $uri = $publisher->getObjectURI($object);
     $description = $publisher->getObjectDescription($object);
 
     $notes = array(
       $description,
       $uri,
       $this->getSynchronizationWarning(),
     );
 
     $notes = implode("\n\n", $notes);
 
     return array(
       'name' => $title,
       'notes' => $notes,
     );
   }
 
   private function getSynchronizationWarning() {
     return
       "\xE2\x9A\xA0 DO NOT EDIT THIS TASK \xE2\x9A\xA0\n".
       "\xE2\x98\xA0 Your changes will not be reflected in Phabricator.\n".
       "\xE2\x98\xA0 Your changes will be destroyed the next time state ".
       "is synchronized.";
   }
 
   protected function doWork() {
     $story = $this->getFeedStory();
     $data = $story->getStoryData();
 
     $viewer = $this->getViewer();
     $provider = $this->getProvider();
     $workspace_id = $this->getWorkspaceID();
 
     $object = $this->getStoryObject();
     $src_phid = $object->getPHID();
 
     $chronological_key = $story->getChronologicalKey();
 
     $publishers = id(new PhutilSymbolLoader())
       ->setAncestorClass('DoorkeeperFeedStoryPublisher')
       ->loadObjects();
     foreach ($publishers as $publisher) {
       if ($publisher->canPublishStory($story, $object)) {
         $publisher
           ->setViewer($viewer)
           ->setFeedStory($story);
 
         $object = $publisher->willPublishStory($object);
         $this->storyObject = $object;
 
         $this->publisher = $publisher;
         $this->log("Using publisher '%s'.\n", get_class($publisher));
         break;
       }
     }
 
     if (!$this->publisher) {
       $this->log("Story is about an unsupported object type.\n");
       return;
     }
 
     // Figure out all the users related to the object. Users go into one of
     // four buckets:
     //
     //   - Owner: the owner of the object. This user becomes the assigned owner
     //     of the parent task.
     //   - Active: users who are responsible for the object and need to act on
     //     it. For example, reviewers of a "needs review" revision.
     //   - Passive: users who are responsible for the object, but do not need
     //     to act on it right now. For example, reviewers of a "needs revision"
     //     revision.
     //   - Follow: users who are following the object; generally CCs.
 
     $owner_phid = $publisher->getOwnerPHID($object);
     $active_phids = $publisher->getActiveUserPHIDs($object);
     $passive_phids = $publisher->getPassiveUserPHIDs($object);
     $follow_phids = $publisher->getCCUserPHIDs($object);
 
     $all_phids = array();
     $all_phids = array_merge(
       array($owner_phid),
       $active_phids,
       $passive_phids,
       $follow_phids);
     $all_phids = array_unique(array_filter($all_phids));
 
     $phid_aid_map = $this->lookupAsanaUserIDs($all_phids);
     if (!$phid_aid_map) {
       throw new PhabricatorWorkerPermanentFailureException(
         'No related users have linked Asana accounts.');
     }
 
     $owner_asana_id = idx($phid_aid_map, $owner_phid);
     $all_asana_ids = array_select_keys($phid_aid_map, $all_phids);
     $all_asana_ids = array_values($all_asana_ids);
 
     // Even if the actor isn't a reviewer, etc., try to use their account so
     // we can post in the correct voice. If we miss, we'll try all the other
     // related users.
 
     $try_users = array_merge(
       array($data->getAuthorPHID()),
       array_keys($phid_aid_map));
     $try_users = array_filter($try_users);
 
     list($possessed_user, $oauth_token) = $this->findAnyValidAsanaAccessToken(
       $try_users);
     if (!$oauth_token) {
       throw new PhabricatorWorkerPermanentFailureException(
         'Unable to find any Asana user with valid credentials to '.
         'pull an OAuth token out of.');
     }
 
     $etype_main = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANATASK;
     $etype_sub = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANASUBTASK;
 
     $equery = id(new PhabricatorEdgeQuery())
       ->withSourcePHIDs(array($src_phid))
       ->withEdgeTypes(
         array(
           $etype_main,
           $etype_sub,
         ))
       ->needEdgeData(true);
 
     $edges = $equery->execute();
 
     $main_edge = head($edges[$src_phid][$etype_main]);
 
     $main_data = $this->getAsanaTaskData($object) + array(
       'assignee' => $owner_asana_id,
       'followers' => $all_asana_ids,
     );
 
     $extra_data = array();
     if ($main_edge) {
       $extra_data = $main_edge['data'];
 
       $refs = id(new DoorkeeperImportEngine())
         ->setViewer($possessed_user)
         ->withPHIDs(array($main_edge['dst']))
         ->execute();
 
       $parent_ref = head($refs);
       if (!$parent_ref) {
         throw new PhabricatorWorkerPermanentFailureException(
           'DoorkeeperExternalObject could not be loaded.');
       }
 
       if ($parent_ref->getSyncFailed()) {
         throw new Exception(
           'Synchronization of parent task from Asana failed!');
       } else if (!$parent_ref->getIsVisible()) {
         $this->log("Skipping main task update, object is no longer visible.\n");
         $extra_data['gone'] = true;
       } else {
         $edge_cursor = idx($main_edge['data'], 'cursor', 0);
 
         // TODO: This probably breaks, very rarely, on 32-bit systems.
         if ($edge_cursor <= $story->getChronologicalKey()) {
           $this->log("Updating main task.\n");
 
           // We need to synchronize follower data separately.
           $put_data = $main_data;
           unset($put_data['followers']);
 
           $this->makeAsanaAPICall(
             $oauth_token,
             "tasks/".$parent_ref->getObjectID(),
             'PUT',
             $put_data);
 
           // To synchronize follower data, just add all the followers. The task
           // might have additional followers, but we can't really tell how they
           // got there: were they CC'd and then unsubscribed, or did they
           // manually follow the task? Assume the latter since it's easier and
           // less destructive and the former is rare.
 
           if ($main_data['followers']) {
             $this->makeAsanaAPICall(
               $oauth_token,
               'tasks/'.$parent_ref->getObjectID().'/addFollowers',
               'POST',
               array(
                 'followers' => $main_data['followers'],
               ));
           }
         } else {
           $this->log(
             "Skipping main task update, cursor is ahead of the story.\n");
         }
       }
     } else {
       $parent = $this->makeAsanaAPICall(
         $oauth_token,
         'tasks',
         'POST',
         array(
           'workspace' => $workspace_id,
           // NOTE: We initially create parent tasks in the "Later" state but
           // don't update it afterward, even if the corresponding object
           // becomes actionable. The expectation is that users will prioritize
           // tasks in responses to notifications of state changes, and that
           // we should not overwrite their choices.
           'assignee_status' => 'later',
         ) + $main_data);
       $parent_ref = $this->newRefFromResult(
         DoorkeeperBridgeAsana::OBJTYPE_TASK,
         $parent);
 
       $extra_data = array(
         'workspace' => $workspace_id,
       );
     }
 
     $dst_phid = $parent_ref->getExternalObject()->getPHID();
 
     // Update the main edge.
 
     $edge_data = array(
       'cursor' => $story->getChronologicalKey(),
     ) + $extra_data;
 
     $edge_options = array(
       'data' => $edge_data,
     );
 
     id(new PhabricatorEdgeEditor())
       ->setActor($viewer)
       ->addEdge($src_phid, $etype_main, $dst_phid, $edge_options)
       ->save();
 
     if (!$parent_ref->getIsVisible()) {
       throw new PhabricatorWorkerPermanentFailureException(
         'DoorkeeperExternalObject has no visible object on the other side; '.
         'this likely indicates the Asana task has been deleted.');
     }
 
     // Now, handle the subtasks.
 
     $sub_editor = id(new PhabricatorEdgeEditor())
       ->setActor($viewer);
 
     // First, find all the object references in Phabricator for tasks that we
     // know about and import their objects from Asana.
     $sub_edges = $edges[$src_phid][$etype_sub];
     $sub_refs = array();
     $subtask_data = $this->getAsanaSubtaskData($object);
     $have_phids = array();
 
     if ($sub_edges) {
       $refs = id(new DoorkeeperImportEngine())
         ->setViewer($possessed_user)
         ->withPHIDs(array_keys($sub_edges))
         ->execute();
 
       foreach ($refs as $ref) {
         if ($ref->getSyncFailed()) {
           throw new Exception(
             'Synchronization of child task from Asana failed!');
         }
         if (!$ref->getIsVisible()) {
           $ref->getExternalObject()->delete();
           continue;
         }
         $have_phids[$ref->getExternalObject()->getPHID()] = $ref;
       }
     }
 
     // Remove any edges in Phabricator which don't have valid tasks in Asana.
     // These are likely tasks which have been deleted. We're going to respawn
     // them.
     foreach ($sub_edges as $sub_phid => $sub_edge) {
       if (isset($have_phids[$sub_phid])) {
         continue;
       }
 
       $this->log(
         "Removing subtask edge to %s, foreign object is not visible.\n",
         $sub_phid);
       $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid);
       unset($sub_edges[$sub_phid]);
     }
 
 
     // For each active or passive user, we're looking for an existing, valid
     // task. If we find one we're going to update it; if we don't, we'll
     // create one. We ignore extra subtasks that we didn't create (we gain
     // nothing by deleting them and might be nuking something important) and
     // ignore subtasks which have been moved across workspaces or replanted
     // under new parents (this stuff is too edge-casey to bother checking for
     // and complicated to fix, as it needs extra API calls). However, we do
     // clean up subtasks we created whose owners are no longer associated
     // with the object.
 
     $subtask_states = array_fill_keys($active_phids, false) +
                       array_fill_keys($passive_phids, true);
 
     // Continue with only those users who have Asana credentials.
 
     $subtask_states = array_select_keys(
       $subtask_states,
       array_keys($phid_aid_map));
 
     $need_subtasks = $subtask_states;
 
     $user_to_ref_map = array();
     $nuke_refs = array();
     foreach ($sub_edges as $sub_phid => $sub_edge) {
       $user_phid = idx($sub_edge['data'], 'userPHID');
 
       if (isset($need_subtasks[$user_phid])) {
         unset($need_subtasks[$user_phid]);
         $user_to_ref_map[$user_phid] = $have_phids[$sub_phid];
       } else {
         // This user isn't associated with the object anymore, so get rid
         // of their task and edge.
         $nuke_refs[$sub_phid] = $have_phids[$sub_phid];
       }
     }
 
     // These are tasks we know about but which are no longer relevant -- for
     // example, because a user has been removed as a reviewer. Remove them and
     // their edges.
 
     foreach ($nuke_refs as $sub_phid => $ref) {
       $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid);
       $this->makeAsanaAPICall(
         $oauth_token,
         'tasks/'.$ref->getObjectID(),
         'DELETE',
         array());
       $ref->getExternalObject()->delete();
     }
 
     // For each user that we don't have a subtask for, create a new subtask.
 
     foreach ($need_subtasks as $user_phid => $is_completed) {
       $subtask = $this->makeAsanaAPICall(
         $oauth_token,
         'tasks',
         'POST',
         $subtask_data + array(
           'assignee' => $phid_aid_map[$user_phid],
           'completed' => $is_completed,
           'parent' => $parent_ref->getObjectID(),
         ));
 
       $subtask_ref = $this->newRefFromResult(
         DoorkeeperBridgeAsana::OBJTYPE_TASK,
         $subtask);
 
       $user_to_ref_map[$user_phid] = $subtask_ref;
 
       // We don't need to synchronize this subtask's state because we just
       // set it when we created it.
       unset($subtask_states[$user_phid]);
 
       // Add an edge to track this subtask.
       $sub_editor->addEdge(
         $src_phid,
         $etype_sub,
         $subtask_ref->getExternalObject()->getPHID(),
         array(
           'data' => array(
             'userPHID' => $user_phid,
           ),
         ));
     }
 
     // Synchronize all the previously-existing subtasks.
 
     foreach ($subtask_states as $user_phid => $is_completed) {
       $this->makeAsanaAPICall(
         $oauth_token,
         'tasks/'.$user_to_ref_map[$user_phid]->getObjectID(),
         'PUT',
         $subtask_data + array(
           'assignee' => $phid_aid_map[$user_phid],
           'completed' => $is_completed,
         ));
     }
 
     // Update edges on our side.
 
     $sub_editor->save();
 
     // Don't publish the "create" story, since pushing the object into Asana
     // naturally generates a notification which effectively serves the same
-    // purpose as the "create" story.
-    if (!$publisher->isStoryAboutObjectCreation($object)) {
+    // purpose as the "create" story. Similarly, "close" stories generate a
+    // close notification.
+    if (!$publisher->isStoryAboutObjectCreation($object) &&
+        !$publisher->isStoryAboutObjectClosure($object)) {
       // Post the feed story itself to the main Asana task. We do this last
       // because everything else is idempotent, so this is the only effect we
       // can't safely run more than once.
 
       $text = $publisher->getStoryText($object);
 
       $this->makeAsanaAPICall(
         $oauth_token,
         'tasks/'.$parent_ref->getObjectID().'/stories',
         'POST',
         array(
           'text' => $text,
         ));
     }
   }
 
   private function lookupAsanaUserIDs($all_phids) {
     $phid_map = array();
 
     $all_phids = array_unique(array_filter($all_phids));
     if (!$all_phids) {
       return $phid_map;
     }
 
     $provider = PhabricatorAuthProviderOAuthAsana::getAsanaProvider();
 
     $accounts = id(new PhabricatorExternalAccountQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withUserPHIDs($all_phids)
       ->withAccountTypes(array($provider->getProviderType()))
       ->withAccountDomains(array($provider->getProviderDomain()))
       ->execute();
 
     foreach ($accounts as $account) {
       $phid_map[$account->getUserPHID()] = $account->getAccountID();
     }
 
     // Put this back in input order.
     $phid_map = array_select_keys($phid_map, $all_phids);
 
     return $phid_map;
   }
 
   private function findAnyValidAsanaAccessToken(array $user_phids) {
     if (!$user_phids) {
       return array(null, null);
     }
 
     $provider = $this->getProvider();
     $viewer = $this->getViewer();
 
     $accounts = id(new PhabricatorExternalAccountQuery())
       ->setViewer($viewer)
       ->withUserPHIDs($user_phids)
       ->withAccountTypes(array($provider->getProviderType()))
       ->withAccountDomains(array($provider->getProviderDomain()))
       ->execute();
 
     // Reorder accounts in the original order.
     // TODO: This needs to be adjusted if/when we allow you to link multiple
     // accounts.
     $accounts = mpull($accounts, null, 'getUserPHID');
     $accounts = array_select_keys($accounts, $user_phids);
 
     $workspace_id = $this->getWorkspaceID();
 
     foreach ($accounts as $account) {
       // Get a token if possible.
       $token = $provider->getOAuthAccessToken($account);
       if (!$token) {
         continue;
       }
 
       // Verify we can actually make a call with the token, and that the user
       // has access to the workspace in question.
       try {
         id(new PhutilAsanaFuture())
           ->setAccessToken($token)
           ->setRawAsanaQuery("workspaces/{$workspace_id}")
           ->resolve();
       } catch (Exception $ex) {
         // This token didn't make it through; try the next account.
         continue;
       }
 
       $user = id(new PhabricatorPeopleQuery())
         ->setViewer($viewer)
         ->withPHIDs(array($account->getUserPHID()))
         ->executeOne();
       if ($user) {
         return array($user, $token);
       }
     }
 
     return array(null, null);
   }
 
   private function makeAsanaAPICall($token, $action, $method, array $params) {
     foreach ($params as $key => $value) {
       if ($value === null) {
         unset($params[$key]);
       } else if (is_array($value)) {
         unset($params[$key]);
         foreach ($value as $skey => $svalue) {
           $params[$key.'['.$skey.']'] = $svalue;
         }
       }
     }
 
     return id(new PhutilAsanaFuture())
       ->setAccessToken($token)
       ->setMethod($method)
       ->setRawAsanaQuery($action, $params)
       ->resolve();
   }
 
   private function newRefFromResult($type, $result) {
     $ref = id(new DoorkeeperObjectRef())
       ->setApplicationType(DoorkeeperBridgeAsana::APPTYPE_ASANA)
       ->setApplicationDomain(DoorkeeperBridgeAsana::APPDOMAIN_ASANA)
       ->setObjectType($type)
       ->setObjectID($result['id'])
       ->setIsVisible(true);
 
     $xobj = $ref->newExternalObject();
     $ref->attachExternalObject($xobj);
 
     $bridge = new DoorkeeperBridgeAsana();
     $bridge->fillObjectFromData($xobj, $result);
 
     $xobj->save();
 
     return $ref;
   }
 
   public function getMaximumRetryCount() {
     return 4;
   }
 
   public function getWaitBeforeRetry(PhabricatorWorkerTask $task) {
     $count = $task->getFailureCount();
     return (5 * 60) * pow(8, $count);
   }
 
 }